mysqli ) { try { if ( @$this->mysqli->ping() ) { return $this->mysqli; } } catch ( \Throwable $e ) { // Conexion ya cerrada: se abre otra. } $this->mysqli = null; } $this->mysqli = $this->connect(); return $this->mysqli; } /** * Conexion nueva con los datos de wp-config.php (lanza una excepcion si falla). * * POR QUE NO SE USA $wpdb AQUI. No es por comodidad: $wpdb no puede hacer este trabajo, y * usarlo a medias seria peor que no usarlo. * * 1. NO LEE EN STREAMING. Volcar una tabla usa MYSQLI_USE_RESULT: las filas se van * escribiendo al fichero mientras el servidor las sirve. $wpdb->get_results() carga el * resultado entero en memoria, asi que exportar una tabla de 2 GB pediria 2 GB de PHP y * el proceso moriria. $wpdb no ofrece ninguna alternativa: _do_query() es privado. * 2. CAMBIA VARIABLES DE SESION. Importar necesita foreign_key_checks=0, unique_checks=0, * sql_mode='NO_AUTO_VALUE_ON_ZERO' y el juego de caracteres del volcado. En la conexion * de $wpdb eso se lo quedaria WordPress para el resto de la peticion, incluidas las * consultas de otros plugins. * 3. NECESITA LOS CODIGOS DE ERROR. El reintento de un lote cortado decide por errno (1062 * clave duplicada, 1205 y 1213 bloqueos, 2006 y 2013 conexion perdida). $wpdb no los * expone, y con el modo estricto de mysqli que puede activar otro plugin lanzaria * excepciones a mitad de una importacion. * 4. LOS METADATOS Y LOS DATOS TIENEN QUE SALIR DEL MISMO SERVIDOR. Con un db.php * (HyperDB, LudicrousDB) $wpdb puede mandar las lecturas a una replica: la lista de * tablas saldria de un servidor y el volcado de otro, y una copia podria quedarse sin * una tabla sin avisar. * 5. UN FALLO DE $wpdb MATA LA RESTAURACION. Ante un error de consulta o de conexion, * $wpdb imprime HTML o llama a wp_die(), que en una peticion AJAX rompe el JSON y dejaria * la restauracion a medias sin poder deshacerla. Aqui un fallo es un valor de retorno. * * Lo que SI se reutiliza de $wpdb es su forma de interpretar DB_HOST (parse_db_host(), con * puertos, sockets e IPv6) y su criterio con el juego de caracteres (ver get_charset()), para * no tener dos comportamientos distintos en el mismo sitio. Y los nombres de tabla salen de * sus propiedades ($wpdb->options, $wpdb->users, base_prefix), nunca de la peticion. * * Todas las consultas de esta clase van con sentencia preparada, o con los valores por * real_escape_string() y los identificadores por quote_identifier(). Ninguna concatena nada * que venga de $_GET, $_POST ni de una cookie. */ private function connect() { // Interpretar DB_HOST igual que WordPress: "host:puerto", sockets e IPv6. Con // new mysqli( DB_HOST ) a pelo fallaba en hostings con DB_HOST = 'localhost:/ruta.sock'. $host = DB_HOST; $port = null; $socket = null; global $wpdb; if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'parse_db_host' ) ) { $parsed = $wpdb->parse_db_host( DB_HOST ); if ( is_array( $parsed ) ) { list( $host, $port, $socket, $is_ipv6 ) = $parsed; if ( $is_ipv6 && extension_loaded( 'mysqlnd' ) ) { $host = '[' . $host . ']'; } } } // Errores como valor de retorno (como wpdb): el codigo de reintentos e importacion // comprueba errno y no espera excepciones aunque otro plugin active el modo estricto. // phpcs:ignore WordPress.DB.RestrictedFunctions.mysql_mysqli_report -- Restaurar la base de datos necesita conexion propia: la tabla de opciones se importa aparte y se cambia con RENAME TABLE al final. mysqli_report( MYSQLI_REPORT_OFF ); // Solo se guarda la conexion si se establecio (antes quedaba un objeto fallido que // __destruct intentaba cerrar). try { // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli -- Restaurar la base de datos necesita conexion propia: la tabla de opciones se importa aparte y se cambia con RENAME TABLE al final. $mysqli = @new mysqli( $host, DB_USER, DB_PASSWORD, DB_NAME, $port ? (int) $port : null, $socket ? $socket : null ); } catch ( \Throwable $e ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: %s: MySQL error text. */ __( 'Database connection error: %s', 'omnimigrate' ), $e->getMessage() ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } if ( $mysqli->connect_error ) { throw new RuntimeException( // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). sprintf( /* translators: %s: MySQL error text. */ __( 'Database connection error: %s', 'omnimigrate' ), $mysqli->connect_error ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $mysqli->set_charset( $this->get_charset() ); return $mysqli; } /** * Juego de caracteres de la conexion. Como hace wpdb, 'utf8' se trata como 'utf8mb4' * para no perder caracteres de 4 bytes (emojis) al exportar/importar. */ private function get_charset() { $charset = ( defined( 'DB_CHARSET' ) && DB_CHARSET ) ? DB_CHARSET : 'utf8mb4'; return ( 'utf8' === strtolower( $charset ) ) ? 'utf8mb4' : $charset; } /** * Un nombre de tabla o de columna, entre acentos graves y listo para meter en una consulta. * * ES LA UNICA FORMA CORRECTA de citar un identificador en MySQL, y no es la misma que la de * citar un valor: real_escape_string() escapa comillas, barras invertidas y saltos de linea, * pero NO el acento grave, que es justo el caracter que cierra un identificador. MySQL * admite acentos graves dentro de un nombre de tabla si se doblan, asi que una tabla llamada * con uno dentro se salia de las comillas. * * No es teorico: los nombres de tabla no los decide el plugin. Salen de SHOW FULL TABLES de * la base de datos, y despues de una restauracion esos nombres son los que venian DENTRO del * fichero .omni que ha subido el usuario, o sea entrada de fuera. Con esto, un nombre con * acentos graves no puede salirse del identificador por ningun camino. * * Los VALORES no pasan por aqui: van en sentencias preparadas con bind_param() o por * real_escape_string(), que para un valor si es lo correcto. */ private static function quote_identifier( $name ) { return '`' . str_replace( '`', '``', (string) $name ) . '`'; } /** * Tablas de ESTE sitio: tablas reales (las vistas no tienen datos propios y una vista rota, * con un DEFINER que ya no existe, impedia toda copia), sin las temporales de OmniMigrate y * sin las de otra instalacion de WordPress que comparta la base de datos (un clon de WP * Staging, otro sitio del mismo hosting). Antes se copiaban, se restauraban y se deshacian * todas: restaurar un sitio revertia el otro. Las tablas de plugins sin prefijo se conservan. */ public function get_tables() { $mysqli = $this->get_mysqli(); $result = $mysqli->query( "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" ); if ( ! $result ) { throw new RuntimeException( // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). sprintf( /* translators: %s: MySQL error text. */ __( 'The tables could not be listed: %s', 'omnimigrate' ), $mysqli->error ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $all = array(); while ( $row = $result->fetch_row() ) { // Tablas de trabajo de este plugin: nunca entran en el volcado. Se reconoce tambien el // sufijo que llevaban antes del cambio de nombre, por si una version anterior dejo // alguna suelta; si no, se exportaria como si fuera del sitio. if ( preg_match( '/_(?:omni|lyn)(?:tmp|old)$/', $row[0] ) ) { continue; } $all[] = $row[0]; } $result->free(); $foreign = self::foreign_prefixes( $all ); $own = self::own_prefix(); $used = self::used_tables(); $tables = array(); $this->foreign_excluded = array(); $this->foreign_found = $foreign; foreach ( $all as $table ) { // Las que este WordPress usa nunca son ajenas (usuarios compartidos: used_tables()). if ( isset( $used[ strtolower( $table ) ] ) || ! self::is_foreign_table( $table, $foreign, $own ) ) { $tables[] = $table; } else { $this->foreign_excluded[] = $table; } } return $tables; } /** * Tablas de otra instalacion que dejo fuera la ultima llamada a get_tables(), y sus * prefijos: la exportacion los anota y avisa al terminar (una copia sin ellas no se da por * completa sin decirlo). */ public function foreign_excluded() { return array( 'tables' => $this->foreign_excluded, 'prefixes' => $this->foreign_found, ); } /** * Tablas que ESTE WordPress usa aunque lleven el prefijo de otra instalacion: con * CUSTOM_USER_TABLE / CUSTOM_USER_META_TABLE (dos sitios que comparten usuarios) los usuarios * de uno son tablas del otro. Antes se daban por ajenas y las copias de ese sitio salian sin * usuarios. Claves en minusculas. */ public static function used_tables() { global $wpdb; $used = array(); if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { return $used; } $names = method_exists( $wpdb, 'tables' ) ? array_values( (array) $wpdb->tables( 'all', true ) ) : array(); foreach ( array( 'users', 'usermeta' ) as $k ) { if ( ! empty( $wpdb->$k ) ) { $names[] = $wpdb->$k; } } foreach ( $names as $n ) { $used[ strtolower( (string) $n ) ] = true; } return $used; } /** * Tablas de usuarios de este sitio que llevan el prefijo de otra instalacion (usuarios * compartidos): restaurarlas cambia tambien los usuarios de la otra. */ public static function shared_user_tables() { global $wpdb; $own = strtolower( self::own_prefix() ); $out = array(); foreach ( array( 'users', 'usermeta' ) as $k ) { if ( isset( $wpdb->$k ) && (string) $wpdb->$k !== '' && strpos( strtolower( (string) $wpdb->$k ), $own ) !== 0 ) { $out[] = (string) $wpdb->$k; } } return $out; } /** * Todas las tablas reales de la base de datos (de cualquier instalacion). */ public function all_table_names() { $names = array(); $res = $this->get_mysqli()->query( "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" ); if ( $res ) { while ( $row = $res->fetch_row() ) { $names[] = $row[0]; } $res->free(); } return $names; } /** * Prefijo de tablas de este sitio (en multisitio, el de la red). */ public static function own_prefix() { global $wpdb; if ( isset( $wpdb->base_prefix ) && $wpdb->base_prefix !== '' ) { return (string) $wpdb->base_prefix; } return isset( $wpdb->prefix ) ? (string) $wpdb->prefix : 'wp_'; } /** * Prefijos de OTRAS instalaciones de WordPress entre estos nombres de tabla: las que tienen * sus propias options, posts y postmeta. No se exigen users/usermeta: un sitio que comparte * los usuarios de otro (CUSTOM_USER_TABLE) no los tiene, y antes no se reconocia (restaurar * uno revertia el otro). Los subsitios de un multisitio (prefijo propio + numero: wp_2_) son * de este sitio salvo que tengan usuarios propios. Las tablas de plugins no cuentan. Sin * distinguir mayusculas (en Windows MySQL guarda los nombres en minusculas aunque wp-config * diga 'WP_'). */ public static function foreign_prefixes( $names ) { $own = strtolower( self::own_prefix() ); $set = array(); foreach ( $names as $n ) { $set[ strtolower( $n ) ] = true; } $out = array(); foreach ( array_keys( $set ) as $n ) { if ( substr( $n, -7 ) !== 'options' ) { continue; } $p = substr( $n, 0, -7 ); if ( $p === '' || $p === $own ) { continue; } if ( ! isset( $set[ $p . 'posts' ], $set[ $p . 'postmeta' ] ) ) { continue; } $subsite = $own !== '' && strpos( $p, $own ) === 0 && preg_match( '/^\d+_$/', substr( $p, strlen( $own ) ) ); if ( $subsite && ! isset( $set[ $p . 'users' ], $set[ $p . 'usermeta' ] ) ) { continue; } $out[] = $p; } return $out; } /** * true si la tabla es de otra instalacion: empieza por uno de sus prefijos y no por uno * propio mas largo (wp_b_posts es de 'wp_b_' aunque tambien empiece por 'wp_'). */ public static function is_foreign_table( $table, $foreign, $own = null ) { if ( ! $foreign ) { return false; } $t = strtolower( (string) $table ); $best = 0; foreach ( $foreign as $p ) { $p = strtolower( (string) $p ); if ( $p !== '' && strpos( $t, $p ) === 0 && strlen( $p ) > $best ) { $best = strlen( $p ); } } if ( ! $best ) { return false; } $own = strtolower( $own === null ? self::own_prefix() : (string) $own ); return ! ( $own !== '' && strpos( $t, $own ) === 0 && strlen( $own ) > $best ); } /** * Nombres de las tablas que crea un volcado (lineas CREATE TABLE), para reconocer en copias * de versiones anteriores las tablas de otras instalaciones que no se deben restaurar. */ public function dump_table_names( $filepath ) { $names = array(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- El formato .omni se lee y se escribe en streaming; WP_Filesystem trabaja con el fichero entero y no sirve. $fh = @fopen( $filepath, 'rb' ); if ( ! $fh ) { return $names; } while ( ( $line = fgets( $fh ) ) !== false ) { if ( strncasecmp( $line, 'CREATE TABLE', 12 ) === 0 && preg_match( '/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([^`\s(]+)`?/i', $line, $m ) ) { $names[] = $m[1]; } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- El formato .omni se lee y se escribe en streaming; WP_Filesystem trabaja con el fichero entero y no sirve. fclose( $fh ); return $names; } /** * El volcado lo hace SIEMPRE el exportador en PHP: produce un formato controlado que * import_from_file() sabe parsear. Hubo un intento con mysqldump y se descartó por tres * motivos: contaminaba el .sql con el aviso «Using a password on the command line is * insecure» (y eso rompía la importación), sus formatos no siempre los entiende el * parser, y en hostings compartidos fallan el PATH, el puerto o los permisos de exec(). * Se quitó también el código: exec() no hace falta para nada aquí. */ public function export_to_file( $filepath ) { return $this->export_php( $filepath ); } /** * Tamano maximo de cada INSERT del volcado: 512 KB, y nunca mas que max_allowed_packet de * este servidor (menos un margen). Asi un snapshot siempre se puede reproducir en el mismo * servidor, aunque no haya permiso para subir max_allowed_packet. */ private function statement_limit() { $limit = 524288; $res = $this->get_mysqli()->query( 'SELECT @@max_allowed_packet' ); if ( $res ) { $row = $res->fetch_row(); $res->free(); $max = $row ? (int) $row[0] : 0; if ( $max > 0 ) { $limit = (int) max( 16384, min( $limit, $max - 16384 ) ); } } return $limit; } /** * Columnas que se exportan de una tabla: todas menos las generadas (VIRTUAL / STORED), que * MySQL calcula y no admite en un INSERT. null = todas (no hay generadas). */ private function insertable_columns( $table ) { $mysqli = $this->get_mysqli(); $res = $mysqli->query( sprintf( "SELECT COLUMN_NAME, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '%s' ORDER BY ORDINAL_POSITION", $mysqli->real_escape_string( $table ) ) ); if ( ! $res ) { return null; } $cols = array(); $generated = false; while ( $r = $res->fetch_row() ) { if ( preg_match( '/\b(?:VIRTUAL|STORED|PERSISTENT)\b/i', (string) $r[1] ) ) { $generated = true; } else { $cols[] = $r[0]; } } $res->free(); return ( $generated && $cols ) ? $cols : null; } private function export_php( $filepath ) { $mysqli = $this->get_mysqli(); $tables = $this->get_tables(); $limit = $this->statement_limit(); // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- El formato .omni se lee y se escribe en streaming; WP_Filesystem trabaja con el fichero entero y no sirve. // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). $fh = fopen( $filepath, 'wb' ); if ( ! $fh ) { throw new RuntimeException( sprintf( /* translators: %s: file path. */ __( 'The export file could not be opened: %s', 'omnimigrate' ), $filepath ) ); } $buffer = ''; $buffer_limit = 524288; $preamble = "SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;\n"; $preamble .= "SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;\n"; $preamble .= "SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n"; $preamble .= "SET NAMES " . $this->get_charset() . ";\n\n"; $buffer .= $preamble; foreach ( $tables as $table ) { $quoted_table = self::quote_identifier( $table ); $buffer .= sprintf( "DROP TABLE IF EXISTS %s;\n", $quoted_table ); $create_result = $mysqli->query( sprintf( 'SHOW CREATE TABLE %s', $quoted_table ) ); if ( ! $create_result ) { fclose( $fh ); throw new RuntimeException( sprintf( /* translators: 1: table name, 2: MySQL error text. */ __( 'The structure of table %1$s could not be read: %2$s', 'omnimigrate' ), $table, $mysqli->error ) ); } $create_row = $create_result->fetch_row(); $buffer .= $create_row[1] . ";\n\n"; $create_result->free(); $cols = $this->insertable_columns( $table ); $select = $cols ? implode( ',', array_map( function ( $c ) { return self::quote_identifier( $c ); }, $cols ) ) : '*'; $col_list = $cols ? '(' . $select . ') ' : ''; $data_result = $mysqli->query( sprintf( 'SELECT %s FROM %s', $select, $quoted_table ), MYSQLI_USE_RESULT ); if ( ! $data_result ) { // Antes la tabla se quedaba sin datos en la copia sin ningun aviso. fclose( $fh ); throw new RuntimeException( sprintf( /* translators: 1: table name, 2: MySQL error text. */ __( 'The data of table %1$s could not be read: %2$s', 'omnimigrate' ), $table, $mysqli->error ) ); } $num_fields = count( $data_result->fetch_fields() ); $insert_prefix = sprintf( 'INSERT INTO %s %sVALUES ', $quoted_table, $col_list ); $in_batch = false; $stmt_len = 0; while ( $row = $data_result->fetch_row() ) { $values = array(); for ( $i = 0; $i < $num_fields; $i++ ) { if ( $row[ $i ] === null ) { $values[] = 'NULL'; } else { $values[] = "'" . $mysqli->real_escape_string( $row[ $i ] ) . "'"; } } $row_sql = '(' . implode( ',', $values ) . ')'; // Se cierra el INSERT ANTES de que la fila siguiente lo haga pasar del limite // (antes se cerraba despues: 512 KB mas una fila podian superar // max_allowed_packet y el snapshot no se podia reproducir). if ( $in_batch && $stmt_len + 1 + strlen( $row_sql ) + 2 > $limit ) { $buffer .= ";\n"; $in_batch = false; } if ( ! $in_batch ) { $buffer .= $insert_prefix . $row_sql; $stmt_len = strlen( $insert_prefix ) + strlen( $row_sql ); $in_batch = true; } else { $buffer .= ',' . $row_sql; $stmt_len += 1 + strlen( $row_sql ); } if ( strlen( $buffer ) >= $buffer_limit ) { $this->write_dump( $fh, $buffer ); $buffer = ''; } } // Una lectura cortada a mitad de tabla (conexion perdida, timeout) termina el bucle // como si no hubiera mas filas: sin esta comprobacion la tabla quedaba incompleta. $read_errno = (int) $mysqli->errno; $read_error = $mysqli->error; $data_result->free(); if ( $read_errno !== 0 ) { fclose( $fh ); throw new RuntimeException( sprintf( /* translators: 1: table name, 2: MySQL error number, 3: MySQL error text. */ __( 'Incomplete read of table %1$s (%2$d: %3$s)', 'omnimigrate' ), $table, $read_errno, $read_error ) ); } if ( $in_batch ) { $buffer .= ";\n"; } $buffer .= "\n"; if ( strlen( $buffer ) >= $buffer_limit ) { $this->write_dump( $fh, $buffer ); $buffer = ''; } } $buffer .= "SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;\n"; $buffer .= "SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;\n"; $buffer .= "SET SQL_MODE=@OLD_SQL_MODE;\n"; $this->write_dump( $fh, $buffer ); if ( ! fclose( $fh ) ) { throw new RuntimeException( __( 'The SQL dump could not be closed (disk full?)', 'omnimigrate' ) ); } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fclose return true; } /** * Escritura comprobada del volcado. Con el disco o la cuota llenos se lanza una excepcion: * antes el .sql quedaba cortado sin aviso y la copia (o el snapshot) "salia bien". */ private function write_dump( $fh, $data ) { $len = strlen( $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- El formato .omni se lee y se escribe en streaming; WP_Filesystem trabaja con el fichero entero y no sirve. if ( $len > 0 && fwrite( $fh, $data ) !== $len ) { // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- El formato .omni se lee y se escribe en streaming; WP_Filesystem trabaja con el fichero entero y no sirve. fclose( $fh ); // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fclose // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( __( 'The SQL dump could not be written in full (disk full?)', 'omnimigrate' ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } } /** * Errores de MySQL que dejan la conexion inservible (servidor caido, conexion cortada o * matada, paquete mayor que max_allowed_packet): todo lo que siga en esa conexion falla. */ const CONNECTION_ERRORS = array( 1153, 1927, 2006, 2013, 2055, 4031 ); /** * Traducciones aprendidas en esta importacion: colaciones o juegos de caracteres que el * servidor no conoce => equivalente disponible. * * @var array */ private $learned_names = array(); /** * Colaciones utf8mb4 que existen en este servidor (null = aun no consultado). * * @var array|null */ private $server_collations = null; /** * Cache de has_strict_unique() por tabla. * * @var array */ private $strict_unique = array(); /** * Ejecuta el volcado SQL desde $query_offset y cede al llegar a $max_queries sentencias o, * si $max_time > 0, al agotar esos segundos. Garantias: * - $protect_table (las opciones) se importa con otro nombre (_omnitmp). Al terminar * se devuelve 'swap_pending' y el llamador, DESPUES de guardar el offset final, llama a * finish_swap(): el cambio (RENAME TABLE) es atomico y repetirlo es inocuo, asi que un * corte en cualquier punto se puede reanudar. WordPress necesita esa tabla para arrancar. * - Nunca cede justo despues de un DROP/CREATE TABLE. * - Conexion perdida: devuelve 'retry' => true y el offset donde repetir: el de la sentencia * que fallo, o el del DROP de su tabla si fallo un CREATE (si el servidor llego a crearla, * repetir solo el CREATE fallaria siempre). * - Un CREATE TABLE o un INSERT que fallan lanzan una excepcion (disco o tabla llenos, fila * demasiado grande, clave duplicada...): el navegador deshace la importacion. * - $opts['replay']: este lote repite uno que se corto sin guardar su progreso. Hasta la * primera sentencia nueva (o el primer DROP/CREATE), las filas que ya estan se saltan con * INSERT IGNORE; una fila que choca con otra distinta sigue siendo un error. Sin 'replay', * una clave duplicada es un error: suele ser una colacion que junta valores distintos. * - Volcados de versiones anteriores: los INSERT en vistas se omiten (sus filas salen de las * tablas base y en una vista simple se duplicarian), las vistas que fallan se crean al * final y los valores de columnas generadas se cargan a traves de una tabla auxiliar. * $opts['views'] y $opts['pending_views'] pasan esa informacion de un lote al siguiente * (se devuelven actualizados en 'views' y 'pending_views'). * - Colaciones o juegos de caracteres desconocidos en un CREATE (MySQL 8 <-> MariaDB) se * traducen al equivalente disponible, y un CREATE sin COLLATE conserva la colacion de su * origen (ver keep_source_collation()). */ public function import_from_file( $filepath, $query_offset = 0, $max_queries = 1000, $max_time = 0, $protect_table = '', $opts = array() ) { $mysqli = $this->get_mysqli(); $start = microtime( true ); $query_offset = (int) $query_offset; $tmp = $protect_table !== '' ? $protect_table . '_omnitmp' : ''; $replaying = ! empty( $opts['replay'] ); $snapshot_mode = ! empty( $opts['snapshot'] ); $skip_prefixes = ( isset( $opts['skip_prefixes'] ) && is_array( $opts['skip_prefixes'] ) ) ? array_values( $opts['skip_prefixes'] ) : array(); $views = ( isset( $opts['views'] ) && is_array( $opts['views'] ) ) ? array_values( $opts['views'] ) : array(); $pending_views = ( isset( $opts['pending_views'] ) && is_array( $opts['pending_views'] ) ) ? array_values( $opts['pending_views'] ) : array(); // Tablas que este sitio usa aunque lleven un prefijo ajeno (usuarios compartidos). $used = $skip_prefixes ? self::used_tables() : array(); // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- El formato .omni se lee y se escribe en streaming; WP_Filesystem trabaja con el fichero entero y no sirve. $fh = fopen( $filepath, 'rb' ); if ( ! $fh ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: %s: file path. */ __( 'The import file could not be opened: %s', 'omnimigrate' ), $filepath ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } if ( $query_offset === 0 ) { // Para las conexiones nuevas (los lotes siguientes) y nunca a la baja: fijar 64 MB en // un servidor con un valor mayor lo reducia para todos sus sitios y aplicaciones. $mysqli->query( 'SET GLOBAL max_allowed_packet = GREATEST(@@GLOBAL.max_allowed_packet, 67108864)' ); if ( $tmp !== '' ) { // Restos de un intento anterior: la tabla temporal se rehace desde cero. $mysqli->query( sprintf( 'DROP TABLE IF EXISTS %s', self::quote_identifier( $tmp ) ) ); } } // Variables de SESION en CADA lote: cada lote es una peticion (y conexion) nueva. // Antes solo se fijaban en el primero y los siguientes iban en modo estricto // (fallos con fechas 0000-00-00) y sin SET NAMES. $mysqli->query( 'SET foreign_key_checks=0' ); $mysqli->query( 'SET unique_checks=0' ); $mysqli->query( "SET sql_mode='NO_AUTO_VALUE_ON_ZERO'" ); $mysqli->query( 'SET NAMES ' . $this->get_charset() ); if ( $query_offset > 0 ) { fseek( $fh, $query_offset ); } $query_buffer = ''; $queries_executed = 0; $current_offset = $query_offset; $stmt_start = $query_offset; $current_table = ''; $errors = array(); $error_count = 0; $duplicates = 0; $replaced = 0; $view_inserts = 0; $foreign_skipped = 0; // Ultimo DROP TABLE (tabla, offset): su CREATE va siempre en el mismo lote. $last_drop = null; $result = function ( $extra ) use ( &$queries_executed, &$errors, &$error_count, &$duplicates, &$replaced, &$views, &$pending_views, &$view_inserts, &$foreign_skipped ) { return array_merge( array( 'queries' => $queries_executed, 'errors' => $errors, 'error_count' => $error_count, 'duplicates' => $duplicates, 'replaced' => $replaced, 'views' => array_values( array_unique( $views ) ), 'pending_views' => array_values( array_unique( $pending_views ) ), 'view_inserts' => $view_inserts, 'foreign_skipped' => $foreign_skipped, ), $extra ); }; while ( ( $line = fgets( $fh ) ) !== false ) { $line_start = $current_offset; $current_offset = ftell( $fh ); $trimmed = trim( $line ); if ( $query_buffer === '' ) { // Comentarios y lineas en blanco ENTRE sentencias. if ( $trimmed === '' || strpos( $trimmed, '--' ) === 0 || strpos( $trimmed, '/*' ) === 0 ) { continue; } $stmt_start = $line_start; } $query_buffer .= $line; if ( substr( $trimmed, -1 ) !== ';' ) { continue; } $query_to_run = trim( $query_buffer ); $query_buffer = ''; // Las lineas finales del volcado restauran variables guardadas al principio en // @OLD_*, que en una conexion nueva valen NULL (3 errores 1231 por importacion). // La sesion ya se configura arriba en cada lote: se omiten. if ( $query_to_run === '' || preg_match( '/^SET\s+(?:FOREIGN_KEY_CHECKS|UNIQUE_CHECKS|SQL_MODE)\s*=\s*@OLD_/i', $query_to_run ) ) { continue; } $kind = ''; $tok = null; if ( preg_match( '/^(DROP\s+TABLE|CREATE\s+TABLE|INSERT\s+INTO)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?`?([^`\s(;]+)`?/i', $query_to_run, $m, PREG_OFFSET_CAPTURE ) ) { $kind = strtoupper( substr( $m[1][0], 0, 1 ) ); // D, C o I $current_table = $m[2][0]; $tok = array( $m[2][1], strlen( $m[2][0] ) ); } elseif ( preg_match( '/^CREATE\s+(?:OR\s+REPLACE\s+)?(?:ALGORITHM\s*=\s*\w+\s+)?(?:DEFINER\s*=\s*\S+\s+)?(?:SQL\s+SECURITY\s+\w+\s+)?VIEW\s+`?([^`\s(]+)`?/i', $query_to_run, $vm ) ) { $kind = 'V'; $current_table = $vm[1]; $views[] = $current_table; } // Tablas de otra instalacion de WordPress de la misma base de datos (copias de // versiones anteriores): no se tocan, ni siquiera su DROP. if ( $kind !== '' && $skip_prefixes && ! isset( $used[ strtolower( $current_table ) ] ) && self::is_foreign_table( $current_table, $skip_prefixes ) ) { $foreign_skipped++; $queries_executed++; continue; } if ( 'D' === $kind || 'C' === $kind ) { // A partir de aqui la tabla se rehace: nada puede ser ya una repeticion. $replaying = false; } if ( 'D' === $kind ) { $last_drop = array( $current_table, $stmt_start ); } $ok = true; $errno = 0; $error = ''; if ( 'I' === $kind && in_array( $current_table, $views, true ) ) { // INSERT en una vista (volcados de versiones anteriores): sus filas ya vienen de // las tablas base; insertarlas fallaria o, en una vista simple, las duplicaria. $view_inserts++; } else { $target = $current_table; if ( $tok && $tmp !== '' && strcasecmp( $current_table, $protect_table ) === 0 ) { $query_to_run = substr_replace( $query_to_run, $tmp, $tok[0], $tok[1] ); $tok = array( $tok[0], strlen( $tmp ) ); $target = $tmp; } if ( 'C' === $kind ) { $query_to_run = $this->keep_source_collation( $query_to_run ); if ( $this->learned_names ) { $query_to_run = $this->apply_learned_names( $query_to_run ); } } $ok = $mysqli->query( $query_to_run ); if ( ! $ok ) { $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; } // CREATE con una colacion o juego de caracteres que este servidor no conoce: se // traduce al equivalente disponible y se repite (como mucho 5 nombres distintos). // El error queda guardado antes: la consulta de colaciones lo borraria. for ( $attempt = 0; ! $ok && 'C' === $kind && $attempt < 5; $attempt++ ) { if ( ! $this->learn_unknown_name( $errno, $error ) ) { break; } $query_to_run = $this->apply_learned_names( $query_to_run ); $ok = $mysqli->query( $query_to_run ); if ( ! $ok ) { $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; } } if ( ! $ok && 'I' === $kind ) { if ( 1062 === $errno && $snapshot_mode ) { // Deshacer: el snapshot es de este mismo servidor (ninguna colacion junta // valores), asi que una clave repetida solo puede ser una fila escrita // mientras tanto (p. ej. la sesion de un cliente de WooCommerce): gana la // del snapshot. Antes el deshacer fallaba una y otra vez en tiendas. $ok = $mysqli->query( preg_replace( '/^INSERT\s+INTO/i', 'REPLACE INTO', $query_to_run, 1 ) ); if ( $ok ) { $replaced++; } else { $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; } } elseif ( 1062 === $errno && $replaying ) { // Repeticion de un lote cortado: las filas que ya estan se saltan. Dentro // de una transaccion, para no dejar nada a medias si resulta no serlo. $mysqli->query( 'START TRANSACTION' ); $ok = $mysqli->query( preg_replace( '/^INSERT\s+INTO/i', 'INSERT IGNORE INTO', $query_to_run, 1 ) ); if ( ! $ok ) { // El error, antes de cerrar la transaccion: el ROLLBACK lo borraba y el // aviso decia '(0: )' (tabla llena, bloqueo...). $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; } // 'Records: N Duplicates: D' con D < N: alguna fila no estaba y otra // choco con una fila DISTINTA (salvo en tablas cuya unica clave unica // admite NULL: esas filas nunca chocan). Eso no es una repeticion. if ( $ok && preg_match( '/Records:\s*(\d+)\s+Duplicates:\s*(\d+)/', (string) $mysqli->info, $mi ) && (int) $mi[2] < (int) $mi[1] && $this->has_strict_unique( $target ) ) { $mysqli->query( 'ROLLBACK' ); fclose( $fh ); // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: 1: table name, 2: number of clashing rows, 3: total rows. */ __( 'Duplicate keys in table %1$s: %2$d of %3$d rows clash with different rows that already exist (rows written during the restore, or a different collation on this server). Stopping so that the import can be undone.', 'omnimigrate' ), $current_table, (int) $mi[2], (int) $mi[1] ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } if ( ! $ok ) { $mysqli->query( 'ROLLBACK' ); } elseif ( $mysqli->query( 'COMMIT' ) ) { $duplicates++; } else { // COMMIT fallido (conexion perdida): la sentencia no cuenta como hecha; // con un error de conexion se repite en la siguiente peticion. $ok = false; $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; } } elseif ( 1213 === $errno || 1205 === $errno ) { // Bloqueo pasajero (otra conexion usando la tabla): un reintento. usleep( 500000 ); $ok = $mysqli->query( $query_to_run ); if ( ! $ok ) { $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; } } elseif ( 3105 === $errno && $tok ) { // Volcados de versiones anteriores con valores de columnas generadas. $fresh = false; $ok = $this->insert_through_scratch( $query_to_run, $target, $tok, $replaying, $snapshot_mode, $errno, $error, $fresh ); if ( $ok && $fresh ) { $replaying = false; } } } elseif ( $ok && 'I' === $kind ) { // Primera sentencia nueva: lo anterior era la parte repetida. $replaying = false; } } if ( ! $ok ) { if ( in_array( $errno, self::CONNECTION_ERRORS, true ) ) { fclose( $fh ); $this->mysqli = null; $retry_at = ( 'C' === $kind && $last_drop && $last_drop[0] === $current_table ) ? $last_drop[1] : $stmt_start; $errors[] = $errno . ': ' . $error; return $result( array( 'done' => false, 'retry' => true, 'offset' => $retry_at, 'retry_error' => $errno . ': ' . $error, 'error_count' => $error_count + 1, ) ); } if ( 'C' === $kind || 'I' === $kind ) { fclose( $fh ); // Dos frases enteras en vez de una con el principio intercambiable: en otros // idiomas el verbo no cae donde cae en castellano y no habria forma de // traducir el trozo suelto. // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( 'C' === $kind /* translators: 1: table name, 2: MySQL error number, 3: MySQL error text, 4: extra note, may be empty. */ ? __( 'Table %1$s could not be created (%2$d: %3$s%4$s). Stopping so that the import can be undone.', 'omnimigrate' ) /* translators: 1: table name, 2: MySQL error number, 3: MySQL error text, 4: extra note, may be empty. */ : __( 'Data could not be inserted into table %1$s (%2$d: %3$s%4$s). Stopping so that the import can be undone.', 'omnimigrate' ), $current_table, $errno, $error, 1062 === $errno ? __( '; duplicate key: rows written during the restore, or a different collation on this server', 'omnimigrate' ) : '' ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } if ( 'V' === $kind ) { // Su tabla base puede ir despues en el volcado: se vuelve a intentar al final. $pending_views[] = $query_to_run; } else { $error_count++; if ( count( $errors ) < 20 ) { $errors[] = $error; } // El error ya queda en .import.log, que es lo que lee la pantalla. En el log de // PHP solo cuando el sitio esta en modo depuracion: WordPress.org no admite // error_log() suelto en codigo de produccion. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Solo con WP_DEBUG puesto: es el unico sitio donde queda constancia si no se puede escribir el .import.log del propio plugin. error_log( sprintf( 'OmniMigrate_Database import error at offset %d: %s', $stmt_start, $error ) ); } } } $queries_executed++; $may_yield = ( 'D' !== $kind && 'C' !== $kind ); if ( $may_yield && ( $queries_executed >= $max_queries || ( $max_time > 0 && ( microtime( true ) - $start ) >= $max_time ) ) ) { fclose( $fh ); return $result( array( 'done' => false, 'offset' => $current_offset, ) ); } } if ( trim( $query_buffer ) !== '' ) { if ( ! $mysqli->query( trim( $query_buffer ) ) ) { $error_count++; if ( count( $errors ) < 20 ) { $errors[] = $mysqli->error; } } $queries_executed++; } fclose( $fh ); // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fclose // Vistas que no se pudieron crear en su sitio (su tabla base iba despues, o su DEFINER no // existe aqui): ahora, con todas las tablas, como CREATE OR REPLACE y con el usuario actual. // En pasadas: una vista puede depender de otra que tambien estaba pendiente. $todo = array(); foreach ( array_unique( $pending_views ) as $sql ) { $sql = preg_replace( '/^CREATE\s+(?!OR\s+REPLACE\b)/i', 'CREATE OR REPLACE ', $sql, 1 ); $todo[] = preg_replace( '/\s+DEFINER\s*=\s*(?:CURRENT_USER(?:\s*\(\s*\))?|(?:`[^`]*`|\'[^\']*\'|[^\s@]+)@(?:`[^`]*`|\'[^\']*\'|\S+))/i', '', $sql, 1 ); } $last_errors = array(); for ( $pass = 0; $todo && $pass < count( $pending_views ) + 1; $pass++ ) { $failed = array(); $last_errors = array(); foreach ( $todo as $sql ) { if ( ! $mysqli->query( $sql ) ) { $failed[] = $sql; $last_errors[] = $mysqli->error; } } if ( count( $failed ) === count( $todo ) ) { break; // ninguna avanzo: no habra mas progreso } $todo = $failed; } foreach ( $last_errors as $e ) { $error_count++; if ( count( $errors ) < 20 ) { $errors[] = 'View not restored: ' . $e; } } $pending_views = array(); $mysqli->query( 'SET foreign_key_checks=1' ); $mysqli->query( 'SET unique_checks=1' ); return $result( array( 'done' => true, 'offset' => $current_offset, // El llamador guarda este offset final y despues llama a finish_swap(). 'swap_pending' => $tmp !== '' && $this->table_exists( $tmp ), ) ); } /** * Activa la tabla protegida importada (ver import_from_file()). Repetirlo es inocuo: sin la * tabla temporal, el cambio ya se hizo. */ public function finish_swap( $protect_table ) { if ( $protect_table !== '' ) { $this->swap_in_table( $protect_table, $protect_table . '_omnitmp' ); } } /** * MySQL 5.7 y MariaDB 10 no escriben COLLATE cuando es la colacion por defecto de su juego de * caracteres (utf8mb4_general_ci, utf8_general_ci), y MySQL 8 tampoco en utf8mb3. Sin COLLATE * el servidor de destino aplicaba la suya (MySQL 8: utf8mb4_0900_ai_ci), que junta valores que * el origen distinguia ('straße' y 'strasse', 'ABC' y 'abc') y una clave unica perdia filas. * Se fija la del origen: _general_ci. */ private function keep_source_collation( $sql ) { // Columnas (o tabla) con CHARACTER SET y sin COLLATE. $sql = preg_replace_callback( '/\bCHARACTER\s+SET\s+(utf8mb4|utf8mb3|utf8)\b(?!\s+COLLATE)/i', function ( $m ) { return $m[0] . ' COLLATE ' . ( strtolower( $m[1] ) === 'utf8mb4' ? 'utf8mb4' : 'utf8' ) . '_general_ci'; }, $sql ); // Opciones de la tabla (tras el parentesis que cierra las columnas) sin COLLATE. $close = strrpos( $sql, "\n)" ); if ( $close === false ) { $close = strrpos( $sql, ')' ); } if ( $close === false ) { return $sql; } $options = substr( $sql, $close ); if ( preg_match( '/\bCOLLATE\b/i', $options ) ) { return $sql; } $options = preg_replace_callback( '/\bCHARSET\s*=?\s*(utf8mb4|utf8mb3|utf8)\b/i', function ( $m ) { return $m[0] . ' COLLATE=' . ( strtolower( $m[1] ) === 'utf8mb4' ? 'utf8mb4' : 'utf8' ) . '_general_ci'; }, $options, 1 ); return substr( $sql, 0, $close ) . $options; } /** * INSERT de un volcado de una version anterior con valores de columnas generadas (MySQL los * rechaza: 3105). Se carga en una tabla auxiliar con las mismas columnas como columnas * normales y se copian solo las no generadas (MySQL recalcula las demas). Antes la tabla * quedaba vacia y la restauracion "terminaba bien". Las claves repetidas siguen las mismas * reglas que un INSERT normal ($fresh = true si la sentencia no era una repeticion). * Devuelve false con $errno/$error del paso que falle (sin lanzar: el llamador cierra). */ private function insert_through_scratch( $sql, $table, $tok, $replaying, $snapshot_mode, &$errno, &$error, &$fresh ) { $mysqli = $this->get_mysqli(); $fresh = false; $res = $mysqli->query( sprintf( "SELECT COLUMN_NAME, COLUMN_TYPE, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '%s' ORDER BY ORDINAL_POSITION", $mysqli->real_escape_string( $table ) ) ); if ( ! $res ) { $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; return false; } $defs = array(); $plain = array(); while ( $r = $res->fetch_row() ) { $col = self::quote_identifier( $r[0] ); $defs[] = $col . ' ' . $r[1] . ' NULL'; if ( ! preg_match( '/\b(?:VIRTUAL|STORED|PERSISTENT)\b/i', (string) $r[2] ) ) { $plain[] = $col; } } $res->free(); if ( ! $defs || ! $plain ) { return false; // queda el error 3105 original } // Nombre corto y terminado en _omnitmp: get_tables() nunca exporta un resto. $scratch = 'omnigen_' . substr( md5( $table ), 0, 12 ) . '_omnitmp'; $qs = self::quote_identifier( $scratch ); $copy = function ( $verb ) use ( $table, $plain, $qs ) { return sprintf( '%s INTO %s (%s) SELECT %s FROM %s', $verb, self::quote_identifier( $table ), implode( ',', $plain ), implode( ',', $plain ), $qs ); }; $fail = function () use ( $mysqli, &$errno, &$error ) { $errno = (int) $mysqli->errno; $error = (string) $mysqli->error; return false; }; $mysqli->query( 'DROP TABLE IF EXISTS ' . $qs ); $ok = $mysqli->query( 'CREATE TABLE ' . $qs . ' (' . implode( ', ', $defs ) . ') DEFAULT CHARSET=' . $this->get_charset() ) && $mysqli->query( substr_replace( $sql, $scratch, $tok[0], $tok[1] ) ); if ( ! $ok ) { $fail(); } else { $ok = $mysqli->query( $copy( 'INSERT' ) ); if ( $ok ) { $fresh = true; } elseif ( 1062 === (int) $mysqli->errno && $snapshot_mode ) { $ok = $mysqli->query( $copy( 'REPLACE' ) ) ? true : $fail(); } elseif ( 1062 === (int) $mysqli->errno && $replaying ) { $mysqli->query( 'START TRANSACTION' ); $ok = $mysqli->query( $copy( 'INSERT IGNORE' ) ); if ( ! $ok ) { $fail(); // antes del ROLLBACK, que borraria el error $mysqli->query( 'ROLLBACK' ); } elseif ( preg_match( '/Records:\s*(\d+)\s+Duplicates:\s*(\d+)/', (string) $mysqli->info, $mi ) && (int) $mi[2] < (int) $mi[1] && $this->has_strict_unique( $table ) ) { $mysqli->query( 'ROLLBACK' ); $errno = 1062; $error = sprintf( /* translators: 1: number of clashing rows, 2: total rows. */ __( 'duplicate keys: %1$d of %2$d rows clash with different rows that already exist', 'omnimigrate' ), (int) $mi[2], (int) $mi[1] ); $ok = false; } elseif ( ! $mysqli->query( 'COMMIT' ) ) { $ok = $fail(); } } else { $fail(); } } $mysqli->query( 'DROP TABLE IF EXISTS ' . $qs ); return (bool) $ok; } /** * true si la tabla tiene una clave primaria o una clave unica con todas sus columnas NOT * NULL: entonces repetir una sentencia ya aplicada hace chocar TODAS sus filas. Con claves * que admiten NULL las filas con NULL nunca chocan y no se puede juzgar asi. */ private function has_strict_unique( $table ) { if ( isset( $this->strict_unique[ $table ] ) ) { return $this->strict_unique[ $table ]; } $mysqli = $this->get_mysqli(); $res = $mysqli->query( sprintf( "SELECT s.INDEX_NAME, MAX(c.IS_NULLABLE = 'YES') FROM information_schema.STATISTICS s JOIN information_schema.COLUMNS c ON c.TABLE_SCHEMA = s.TABLE_SCHEMA AND c.TABLE_NAME = s.TABLE_NAME AND c.COLUMN_NAME = s.COLUMN_NAME WHERE s.TABLE_SCHEMA = DATABASE() AND s.TABLE_NAME = '%s' AND s.NON_UNIQUE = 0 GROUP BY s.INDEX_NAME", $mysqli->real_escape_string( $table ) ) ); $strict = false; if ( $res ) { while ( $r = $res->fetch_row() ) { if ( ! (int) $r[1] ) { $strict = true; } } $res->free(); } $this->strict_unique[ $table ] = $strict; return $strict; } private function table_exists( $table ) { $mysqli = $this->get_mysqli(); $res = $mysqli->query( sprintf( "SHOW TABLES LIKE '%s'", $mysqli->real_escape_string( addcslashes( $table, '\\%_' ) ) ) ); $exists = $res && $res->num_rows > 0; if ( $res ) { $res->free(); } return $exists; } /** * Cambia $live por $tmp en una sola operacion (RENAME TABLE es atomico). Si no hay $tmp * (el volcado no traia esa tabla), no se toca nada. */ private function swap_in_table( $live, $tmp ) { if ( ! $this->table_exists( $tmp ) ) { return; } $mysqli = $this->get_mysqli(); $q = function ( $t ) { return self::quote_identifier( $t ); }; $old = $live . '_omniold'; $mysqli->query( 'DROP TABLE IF EXISTS ' . $q( $old ) ); $ok = $this->table_exists( $live ) ? $mysqli->query( sprintf( 'RENAME TABLE %s TO %s, %s TO %s', $q( $live ), $q( $old ), $q( $tmp ), $q( $live ) ) ) : $mysqli->query( sprintf( 'RENAME TABLE %s TO %s', $q( $tmp ), $q( $live ) ) ); if ( ! $ok ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: 1: table name, 2: MySQL error text. */ __( 'The imported table %1$s could not be put in place: %2$s', 'omnimigrate' ), $live, $mysqli->error ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $mysqli->query( 'DROP TABLE IF EXISTS ' . $q( $old ) ); } /** * Aprende la traduccion del nombre desconocido de un error de CREATE TABLE: * - 1273 colacion desconocida: equivalente disponible (ver collation_target()). * - 1115 juego de caracteres desconocido: 'utf8mb3' (MySQL 8) -> 'utf8' (MySQL 5.7). * Devuelve false si no hay traduccion posible. */ private function learn_unknown_name( $errno, $error ) { if ( ! preg_match( "/'([A-Za-z0-9_]+)'/", $error, $m ) ) { return false; } $name = strtolower( $m[1] ); if ( isset( $this->learned_names[ $name ] ) ) { return false; // ya se tradujo y sigue fallando } if ( 1115 === $errno && 'utf8mb3' === $name ) { $this->learned_names['utf8mb3'] = 'utf8'; return true; } if ( 1273 === $errno ) { $target = $this->collation_target( $name ); if ( $target !== '' && $target !== $name ) { $this->learned_names[ $name ] = $target; return true; } } return false; } private function apply_learned_names( $sql ) { foreach ( $this->learned_names as $from => $to ) { $sql = preg_replace( '/\b' . preg_quote( $from, '/' ) . '(?=[_\s,;)\'"`]|$)/i', $to, $sql ); } return $sql; } /** * Colacion de este servidor equivalente a una que no conoce (p. ej. utf8mb4_0900_ai_ci de * MySQL 8 en MariaDB, o utf8mb4_uca1400_ai_ci de MariaDB 11 en MySQL). Solo las genericas * sin distinguir acentos ni mayusculas pasan a otra _ci; el resto (sensibles a acentos o * mayusculas, binarias o de un idioma) pasan a binaria, que nunca junta valores distintos * (una _ci podia hacer chocar 'ano' y 'año' en una clave UNIQUE y perder filas). */ private function collation_target( $name ) { if ( $this->server_collations === null ) { $this->server_collations = array(); $res = $this->get_mysqli()->query( "SHOW COLLATION LIKE 'utf8%'" ); if ( $res ) { while ( $r = $res->fetch_row() ) { $this->server_collations[ strtolower( $r[0] ) ] = true; } $res->free(); } } $has = $this->server_collations; $first = function ( $list ) use ( $has ) { foreach ( $list as $c ) { if ( isset( $has[ $c ] ) ) { return $c; } } return ''; }; // utf8 / utf8mb3: el servidor puede listarlas con cualquiera de las dos grafias (MySQL // 8.0.30+ y MariaDB 10.6+ usan utf8mb3_*) y acepta las dos. Mismo criterio que utf8mb4. if ( preg_match( '/^utf8(?:mb3)?_(.+)$/', $name, $sm ) ) { $both = function ( $suffixes ) { $out = array(); foreach ( $suffixes as $s ) { $out[] = 'utf8mb3_' . $s; $out[] = 'utf8_' . $s; } return $out; }; $suffix = $sm[1]; $same = $first( $both( array( $suffix ) ) ); if ( $same !== '' ) { return $same; } $nopad = strpos( $suffix, 'nopad' ) !== false || strpos( $suffix, '0900' ) !== false; if ( preg_match( '/^(?:general|unicode|unicode_520|0900|uca1400)(?:_nopad)?(?:_ai)?_ci$/', $suffix ) ) { return $first( $both( $nopad ? array( 'unicode_520_nopad_ci', 'unicode_nopad_ci', 'general_nopad_ci', 'unicode_520_ci', 'unicode_ci', 'general_ci' ) : array( 'unicode_520_ci', 'unicode_ci', 'general_ci' ) ) ); } return $first( $both( $nopad ? array( 'nopad_bin', 'bin' ) : array( 'bin', 'nopad_bin' ) ) ); } if ( strpos( $name, 'utf8mb4_' ) !== 0 ) { return ''; // otros juegos de caracteres: sin traduccion (se muestra el error real) } $generic_ci = (bool) preg_match( '/^utf8mb4_(?:0900|uca1400)(?:_nopad)?_ai_ci$/', $name ); $no_pad = strpos( $name, '0900' ) !== false || strpos( $name, 'nopad' ) !== false; if ( $generic_ci ) { return $first( $no_pad ? array( 'utf8mb4_0900_ai_ci', 'utf8mb4_uca1400_nopad_ai_ci', 'utf8mb4_unicode_520_nopad_ci', 'utf8mb4_unicode_520_ci', 'utf8mb4_unicode_ci', 'utf8mb4_general_ci' ) : array( 'utf8mb4_uca1400_ai_ci', 'utf8mb4_unicode_520_ci', 'utf8mb4_unicode_ci', 'utf8mb4_0900_ai_ci', 'utf8mb4_general_ci' ) ); } return $first( $no_pad ? array( 'utf8mb4_0900_bin', 'utf8mb4_nopad_bin', 'utf8mb4_bin' ) : array( 'utf8mb4_bin', 'utf8mb4_0900_bin', 'utf8mb4_nopad_bin' ) ); } /** * Reemplaza URLs en todas las columnas de texto, de forma segura con datos serializados. * - Paginacion por clave primaria (keyset): cada fila se visita una sola vez, aunque el * valor nuevo siga conteniendo al antiguo (antes el bucle "auto-drenante" podia no acabar). * - siteurl/home se excluyen: los fija force_site_urls(). * - Reanudable: $table_offset es el cursor (JSON) dentro de $table_index. * - $pass identifica la pasada (una por importacion): nombra las listas de trabajo de las * tablas sin clave primaria (ver replace_in_table_without_pk()). */ public function replace_urls_batch( $table_index, $table_offset, $replacements, $max_time = 10, $pass = '' ) { global $wpdb; $mysqli = $this->get_mysqli(); $tables = $this->get_tables(); $total = count( $tables ); if ( $table_index >= $total ) { return array( 'done' => true, 'table_index' => $table_index, 'table_offset' => '' ); } $search = array_keys( $replacements ); $replace = array_values( $replacements ); $pass = (string) $pass !== '' ? (string) $pass : wp_json_encode( $replacements ); $start_time = microtime( true ); $page = 500; // claves por consulta $chunk_max = 2097152; // bytes de filas en memoria a la vez (aprox.) $options = isset( $wpdb->options ) ? $wpdb->options : ''; $quote = function ( $c ) { return self::quote_identifier( $c ); }; $values = function ( $vals ) use ( $mysqli ) { return implode( ',', array_map( function ( $v ) use ( $mysqli ) { return "'" . $mysqli->real_escape_string( (string) $v ) . "'"; }, $vals ) ); }; $late = function () use ( $start_time, $max_time ) { return ( microtime( true ) - $start_time ) >= $max_time; }; for ( $t = $table_index; $t < $total; $t++ ) { $table = $tables[ $t ]; $qt = $quote( $table ); $pk = $this->get_primary_key( $table ); // Nunca se reescriben columnas de la clave primaria. $columns = array_values( array_diff( $this->get_text_columns( $table ), $pk ) ); if ( empty( $columns ) ) { $table_offset = ''; continue; } if ( empty( $pk ) ) { // Mismo presupuesto que las demas, con cursor {nopk: columna} dentro de la tabla. $cursor = ( $table_offset !== '' ) ? json_decode( $table_offset, true ) : null; $table_offset = ''; $next = $this->replace_in_table_without_pk( $table, $columns, $search, $replace, $pass, is_array( $cursor ) ? $cursor : null, $late ); if ( is_array( $next ) ) { return array( 'done' => false, 'table_index' => $t, 'table_offset' => wp_json_encode( $next ) ); } if ( $late() && $t + 1 < $total ) { return array( 'done' => false, 'table_index' => $t + 1, 'table_offset' => '' ); } continue; } $like_parts = array(); foreach ( $columns as $column ) { foreach ( $search as $s ) { $like_parts[] = $quote( $column ) . ' LIKE ' . $this->like_contains( $s ); } } $where = '(' . implode( ' OR ', $like_parts ) . ')'; if ( $table === $options ) { // Reemplazar dentro de siteurl/home duplicaria la URL cuando el destino contiene // al origen (http://localhost -> http://localhost:8080). Los fija force_site_urls(). $where .= " AND `option_name` NOT IN ('siteurl','home')"; } $pk_sql = implode( ',', array_map( $quote, $pk ) ); $cols_sql = implode( ',', array_map( $quote, $columns ) ); // Tamano de cada fila sin traerla: las filas se leen en tandas de unos 2 MB. Antes se // traian 500 de golpe y con datos de Elementor o Divi se agotaba memory_limit en cada // reintento: la migracion no podia terminar. $size_sql = implode( '+', array_map( function ( $c ) use ( $quote ) { return 'COALESCE(LENGTH(' . $quote( $c ) . '),0)'; }, $columns ) ); // Cursor de la tabla: ultima clave procesada y claves de la pagina actual aun sin // procesar. Asi la consulta de claves (que recorre la tabla con LIKE y en tablas grandes // con pocas coincidencias puede comerse casi todo el lote) se hace una vez por cada 500 // coincidencias y no en cada peticion. $cursor = ( $table_offset !== '' ) ? json_decode( $table_offset, true ) : null; $table_offset = ''; $last = null; $pending = array(); $more = true; if ( is_array( $cursor ) && array_key_exists( 'last', $cursor ) ) { $last = is_array( $cursor['last'] ) ? $cursor['last'] : null; $pending = ( isset( $cursor['pending'] ) && is_array( $cursor['pending'] ) ) ? $cursor['pending'] : array(); $more = ! empty( $cursor['more'] ); } elseif ( is_array( $cursor ) ) { $last = $cursor; // formato anterior: solo la ultima clave } while ( true ) { if ( ! $pending ) { if ( ! $more ) { break; // la ultima pagina de claves de la tabla ya esta hecha } $keyset = ( is_array( $last ) && count( $last ) === count( $pk ) ) ? " AND ({$pk_sql}) > (" . $values( $last ) . ')' : ''; $res = $mysqli->query( "SELECT {$pk_sql}, {$size_sql} FROM {$qt} WHERE {$where}{$keyset} ORDER BY {$pk_sql} LIMIT {$page}" ); if ( ! $res ) { break; } while ( $r = $res->fetch_row() ) { $size = (int) array_pop( $r ); $pending[] = array( $r, $size ); } $res->free(); if ( ! $pending ) { break; } $more = ( count( $pending ) === $page ); } // Tanda de filas desde el principio de las pendientes (100 filas o ~2 MB). $chunk = array(); $bytes = 0; foreach ( $pending as $p ) { if ( $chunk && ( count( $chunk ) >= 100 || $bytes + (int) $p[1] > $chunk_max ) ) { break; } $chunk[] = $p; $bytes += (int) $p[1]; } $in = array(); foreach ( $chunk as $p ) { $in[] = '(' . $values( $p[0] ) . ')'; } $rows = $mysqli->query( "SELECT {$pk_sql}, {$cols_sql} FROM {$qt} WHERE ({$pk_sql}) IN (" . implode( ',', $in ) . ") ORDER BY {$pk_sql}" ); if ( ! $rows ) { break; } $processed = array(); while ( $row = $rows->fetch_assoc() ) { $changed = array(); foreach ( $columns as $column ) { $orig = $row[ $column ]; if ( $orig === null || $orig === '' ) { continue; } $new = $this->apply_replacements( $orig, $search, $replace ); if ( $new !== $orig ) { $changed[ $column ] = $new; } } $key = array(); foreach ( $pk as $pkc ) { $key[] = $row[ $pkc ]; } if ( ! empty( $changed ) ) { $set = array(); foreach ( $changed as $column => $value ) { $set[] = $quote( $column ) . " = '" . $mysqli->real_escape_string( $value ) . "'"; } $where_pk = array(); foreach ( $pk as $n => $pkc ) { $where_pk[] = $quote( $pkc ) . " = '" . $mysqli->real_escape_string( (string) $key[ $n ] ) . "'"; } $mysqli->query( sprintf( 'UPDATE %s SET %s WHERE %s', $qt, implode( ',', $set ), implode( ' AND ', $where_pk ) ) ); } $last = $key; $processed[ wp_json_encode( $key ) ] = true; // Presupuesto comprobado fila a fila: se continua en la siguiente peticion con // las claves que quedan de esta pagina, sin repetir su consulta. if ( $late() ) { $rows->free(); $rest = array(); foreach ( $pending as $p ) { if ( ! isset( $processed[ wp_json_encode( array_values( $p[0] ) ) ] ) ) { $rest[] = $p; } } return array( 'done' => false, 'table_index' => $t, 'table_offset' => wp_json_encode( array( 'last' => $last, 'pending' => $rest, 'more' => $more ) ), ); } } $rows->free(); // Las de la tanda que ya no estan (borradas mientras tanto) tambien salen. $pending = array_slice( $pending, count( $chunk ) ); } if ( $late() && $t + 1 < $total ) { return array( 'done' => false, 'table_index' => $t + 1, 'table_offset' => '' ); } } return array( 'done' => true, 'table_index' => $total, 'table_offset' => '' ); } /** * Devuelve las columnas de la clave primaria de una tabla (array, puede ser compuesta). */ private function get_primary_key( $table ) { $mysqli = $this->get_mysqli(); $res = $mysqli->query( sprintf( 'SHOW KEYS FROM %s WHERE Key_name = "PRIMARY"', self::quote_identifier( $table ) ) ); $cols = array(); if ( $res ) { while ( $row = $res->fetch_assoc() ) { if ( isset( $row['Column_name'] ) ) { $cols[] = $row['Column_name']; } } $res->free(); } return $cols; } /** * Aplica str_replace recursivo respetando datos serializados de PHP. * * @param mixed $data Valor a procesar (string, array, object, escalar). * @param array $search Lista de cadenas a buscar (para str_replace). * @param array $replace Lista de reemplazos (paralela a $search). * @return mixed */ private function apply_replacements( $data, $search, $replace ) { if ( is_string( $data ) ) { if ( function_exists( 'is_serialized' ) && is_serialized( $data ) ) { // Solo stdClass: no se instancian clases del sitio al deserializar (sin "gadgets"). // Los objetos de otras clases se conservan intactos (__PHP_Incomplete_Class se // re-serializa con su nombre original). $un = @unserialize( $data, array( 'allowed_classes' => array( 'stdClass' ) ) ); if ( $un !== false || $data === 'b:0;' ) { if ( $this->has_incomplete_class( $un ) ) { // Hay objetos de clases del sitio (no se instancian y antes sus URLs se // quedaban sin migrar): se recorre el texto serializado directamente. // Si no se puede analizar, se deja como estaba. $walked = $this->replace_in_serialized( $data, $search, $replace ); return $walked !== null ? $walked : $data; } return serialize( $this->apply_replacements( $un, $search, $replace ) ); } // unserialize rechaza datos validos que no sabe reconstruir sin las clases del // sitio (C: de Serializable, E: de enums): antes se hacia una sustitucion directa // que rompia las longitudes s:N: y WordPress perdia la opcion entera. Se recorre // el texto; solo si tampoco es valido (datos ya corruptos) se sustituye directo. $walked = $this->replace_in_serialized( $data, $search, $replace ); if ( $walked !== null ) { return $walked; } } return $this->replace_once( $search, $replace, $data ); } if ( is_array( $data ) ) { $out = array(); foreach ( $data as $k => $v ) { $out[ $k ] = $this->apply_replacements( $v, $search, $replace ); } return $out; } if ( is_object( $data ) ) { if ( $data instanceof __PHP_Incomplete_Class || $data instanceof \UnitEnum ) { return $data; // sin tocar (los enums ademas no se pueden clonar) } $out = clone $data; foreach ( get_object_vars( $data ) as $k => $v ) { $out->$k = $this->apply_replacements( $v, $search, $replace ); } return $out; } return $data; } /** * true si el valor deserializado contiene objetos de clases no cargadas * (__PHP_Incomplete_Class) a cualquier profundidad. */ private function has_incomplete_class( $data, $depth = 0 ) { if ( $data instanceof __PHP_Incomplete_Class || $depth > 512 ) { return true; } if ( is_array( $data ) || is_object( $data ) ) { foreach ( ( is_object( $data ) ? get_object_vars( $data ) : $data ) as $v ) { if ( ( is_array( $v ) || is_object( $v ) ) && $this->has_incomplete_class( $v, $depth + 1 ) ) { return true; } } } return false; } /** * Sustituye en un valor serializado SIN deserializarlo: recorre el formato de PHP, aplica * los reemplazos a cada cadena (s:N:"...") y corrige su longitud. Las claves, los numeros, * las referencias y los formatos opacos (C:, E:) se copian tal cual, asi que el resultado * conserva la estructura y las referencias. Devuelve null si el texto no es valido. */ private function replace_in_serialized( $s, $search, $replace ) { $i = 0; $out = $this->walk_serialized( $s, $i, $search, $replace, 0 ); if ( $out === null || rtrim( substr( $s, $i ) ) !== '' ) { return null; } return $out . substr( $s, $i ); } private function walk_serialized( $s, &$i, $search, $replace, $depth ) { if ( $depth > 512 || ! isset( $s[ $i ] ) ) { return null; } switch ( $s[ $i ] ) { case 'N': if ( substr( $s, $i, 2 ) !== 'N;' ) { return null; } $i += 2; return 'N;'; case 'b': case 'i': case 'd': case 'r': case 'R': if ( ! preg_match( '/\G(?:b:[01]|i:[+-]?\d+|d:(?:[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|-?INF|NAN)|[rR]:\d+);/', $s, $m, 0, $i ) ) { return null; } $i += strlen( $m[0] ); return $m[0]; case 's': if ( ! preg_match( '/\Gs:(\d+):"/', $s, $m, 0, $i ) ) { return null; } $start = $i + strlen( $m[0] ); $n = (int) $m[1]; if ( substr( $s, $start + $n, 2 ) !== '";' ) { return null; } $i = $start + $n + 2; $new = $this->apply_replacements( substr( $s, $start, $n ), $search, $replace ); return 's:' . strlen( $new ) . ':"' . $new . '";'; case 'a': if ( ! preg_match( '/\Ga:(\d+):\{/', $s, $m, 0, $i ) ) { return null; } $i += strlen( $m[0] ); return $this->walk_serialized_members( $s, $i, (int) $m[1], $m[0], $search, $replace, $depth ); case 'O': if ( ! preg_match( '/\GO:(\d+):"/', $s, $m, 0, $i ) ) { return null; } $name_end = $i + strlen( $m[0] ) + (int) $m[1]; if ( ! preg_match( '/\G":(\d+):\{/', $s, $m2, 0, $name_end ) ) { return null; } $head = substr( $s, $i, $name_end - $i ) . $m2[0]; $i = $name_end + strlen( $m2[0] ); return $this->walk_serialized_members( $s, $i, (int) $m2[1], $head, $search, $replace, $depth ); case 'C': // Serializable con formato propio: datos opacos, se copian tal cual. if ( ! preg_match( '/\GC:(\d+):"/', $s, $m, 0, $i ) ) { return null; } $name_end = $i + strlen( $m[0] ) + (int) $m[1]; if ( ! preg_match( '/\G":(\d+):\{/', $s, $m2, 0, $name_end ) ) { return null; } $close = $name_end + strlen( $m2[0] ) + (int) $m2[1]; if ( ! isset( $s[ $close ] ) || $s[ $close ] !== '}' ) { return null; } $out = substr( $s, $i, $close + 1 - $i ); $i = $close + 1; return $out; case 'E': if ( ! preg_match( '/\GE:(\d+):"/', $s, $m, 0, $i ) ) { return null; } $end = $i + strlen( $m[0] ) + (int) $m[1]; if ( substr( $s, $end, 2 ) !== '";' ) { return null; } $out = substr( $s, $i, $end + 2 - $i ); $i = $end + 2; return $out; } return null; } private function walk_serialized_members( $s, &$i, $count, $out, $search, $replace, $depth ) { for ( $k = 0; $k < $count; $k++ ) { // Claves de array y nombres de propiedad: tal cual (no se sustituye en ellas). if ( preg_match( '/\Gi:[+-]?\d+;/', $s, $m, 0, $i ) ) { $out .= $m[0]; $i += strlen( $m[0] ); } elseif ( preg_match( '/\Gs:(\d+):"/', $s, $m, 0, $i ) ) { $end = $i + strlen( $m[0] ) + (int) $m[1]; if ( substr( $s, $end, 2 ) !== '";' ) { return null; } $out .= substr( $s, $i, $end + 2 - $i ); $i = $end + 2; } else { return null; } $v = $this->walk_serialized( $s, $i, $search, $replace, $depth + 1 ); if ( $v === null ) { return null; } $out .= $v; } if ( ! isset( $s[ $i ] ) || $s[ $i ] !== '}' ) { return null; } $i++; return $out . '}'; } public function create_snapshot( $filepath ) { return $this->export_to_file( $filepath ); } public function restore_snapshot( $filepath, $offset = 0, $max_time = 0, $protect_table = '', $replay = false ) { // 'snapshot': las claves repetidas son filas escritas mientras tanto (gana el snapshot). $opts = array( 'replay' => (bool) $replay, 'snapshot' => true ); return $max_time > 0 ? $this->import_from_file( $filepath, $offset, 1000000, $max_time, $protect_table, $opts ) : $this->import_from_file( $filepath, $offset, 1000, 0, $protect_table, $opts ); } public function force_site_urls( $siteurl, $home ) { $mysqli = $this->get_mysqli(); global $wpdb; $options_table = isset( $wpdb->options ) ? $wpdb->options : 'wp_options'; $quoted_table = self::quote_identifier( $options_table ); $stmt = $mysqli->prepare( sprintf( "UPDATE %s SET option_value = ? WHERE option_name = 'siteurl'", $quoted_table ) ); if ( $stmt ) { $stmt->bind_param( 's', $siteurl ); $stmt->execute(); $stmt->close(); } $stmt = $mysqli->prepare( sprintf( "UPDATE %s SET option_value = ? WHERE option_name = 'home'", $quoted_table ) ); if ( $stmt ) { $stmt->bind_param( 's', $home ); $stmt->execute(); $stmt->close(); } return true; } public function __destruct() { if ( $this->mysqli ) { try { @$this->mysqli->close(); } catch ( \Throwable $e ) { // Conexion ya cerrada o rota: nada que hacer. } $this->mysqli = null; } } /** * Garantiza que $basename figura en active_plugins escribiendo directamente en la BD * (la cache de opciones de WordPress queda obsoleta tras importar) y quita otras * copias del mismo plugin en carpetas distintas (evita clases duplicadas). */ public function ensure_plugin_active( $basename, $main_file ) { $mysqli = $this->get_mysqli(); global $wpdb; $table = self::quote_identifier( isset( $wpdb->options ) ? $wpdb->options : 'wp_options' ); $res = $mysqli->query( "SELECT option_value FROM {$table} WHERE option_name = 'active_plugins' LIMIT 1" ); $row = $res ? $res->fetch_row() : null; if ( $res ) { $res->free(); } // Sin instanciar clases: esto viene de la base de datos del backup, que es de otro sitio. $plugins = $row ? @unserialize( $row[0], array( 'allowed_classes' => false ) ) : array(); if ( ! is_array( $plugins ) ) { $plugins = array(); } $plugins = array_values( array_filter( $plugins, function ( $p ) use ( $basename, $main_file ) { return $p === $basename || basename( $p ) !== $main_file; } ) ); if ( ! in_array( $basename, $plugins, true ) ) { $plugins[] = $basename; } $value = serialize( $plugins ); $sql = $row ? "UPDATE {$table} SET option_value = ? WHERE option_name = 'active_plugins'" : "INSERT INTO {$table} (option_name, option_value, autoload) VALUES ('active_plugins', ?, 'yes')"; $stmt = $mysqli->prepare( $sql ); if ( $stmt ) { $stmt->bind_param( 's', $value ); $stmt->execute(); $stmt->close(); } return $plugins; } /** * Hay una tabla con ese nombre exacto. Lo necesita el aislamiento de la restauracion para * saber si el volcado traia la tabla de opciones (se importa como _omnitmp y se activa * con un RENAME al final; ver import_from_file() y finish_swap()). */ public function has_table( $table ) { return '' !== $table && $this->table_exists( $table ); } /** * Tabla de opciones sobre la que trabajan los tres metodos de abajo: vacio es la tabla viva * del sitio, y con nombre se apunta a _omnitmp, la importada que todavia no esta en su * sitio (asi se puede dejar algo escrito DENTRO de ella antes del RENAME). */ private function options_table( $table = '' ) { if ( '' !== $table ) { return (string) $table; } global $wpdb; return isset( $wpdb->options ) ? $wpdb->options : 'wp_options'; } /** * Valor CRUDO (tal cual esta en la columna, todavia serializado) de una fila de opciones, * leido directamente de la base de datos. A mitad de una restauracion la cache de opciones * de WordPress es de antes del cambio y get_option() devolveria lo que ya no hay. Devuelve * null si no existe esa fila. */ public function read_option_row( $option_name, $table = '' ) { $mysqli = $this->get_mysqli(); $t = self::quote_identifier( $this->options_table( $table ) ); // La tabla va por quote_identifier() (es un identificador) y el nombre de la opcion por // real_escape_string() (es un valor). No se usa una sentencia preparada aqui porque leer // su resultado necesitaria get_result(), que solo existe con el controlador mysqlnd, y // este plugin tiene que funcionar tambien en los hostings que compilan mysqli contra // libmysqlclient. Los nombres que llegan son constantes del propio plugin. $n = $mysqli->real_escape_string( (string) $option_name ); $res = $mysqli->query( "SELECT option_value FROM {$t} WHERE option_name = '{$n}' LIMIT 1" ); $row = $res ? $res->fetch_row() : null; if ( $res ) { $res->free(); } return $row ? (string) $row[0] : null; } /** * Escribe una fila de opciones con el valor ya serializado (ver read_option_row()). Queda * siempre AUTOCARGADA, la cree o la actualice: las filas del aislamiento tienen que venir * dentro de alloptions, que es lo que el nucleo lee antes de cargar los plugins, y la del * guardian se lee SOLO de ahi (ver OmniMigrate_Import::read_flag()), asi que una fila con autoload * en 'no' seria un guardian apagado. Las unicas opciones que pasan por aqui son las cinco * que WordPress autocarga de serie (active_plugins, siteurl, home, template, stylesheet) y * la del guardian. Mismo camino que ensure_plugin_active() (SELECT y despues UPDATE o * INSERT): no depende de que la tabla tenga su clave unica, que viene del volcado de otro * servidor. */ public function write_option_row( $option_name, $value, $table = '' ) { $mysqli = $this->get_mysqli(); $t = self::quote_identifier( $this->options_table( $table ) ); $exists = null !== $this->read_option_row( $option_name, $table ); $stmt = $mysqli->prepare( $exists ? "UPDATE {$t} SET option_value = ?, autoload = 'yes' WHERE option_name = ?" : "INSERT INTO {$t} (option_name, option_value, autoload) VALUES (?, ?, 'yes')" ); if ( ! $stmt ) { return false; } $name = (string) $option_name; $val = (string) $value; // El UPDATE lleva el valor primero y el nombre despues; el INSERT, al contrario. if ( $exists ) { $stmt->bind_param( 'ss', $val, $name ); } else { $stmt->bind_param( 'ss', $name, $val ); } $ok = $stmt->execute(); $stmt->close(); return (bool) $ok; } /** * Borra una fila de opciones: la del guardian al terminar la restauracion, o una que el * sitio de destino no tenia y que el deshacer tiene que dejar como estaba. */ public function delete_option_row( $option_name, $table = '' ) { $mysqli = $this->get_mysqli(); $t = self::quote_identifier( $this->options_table( $table ) ); $stmt = $mysqli->prepare( "DELETE FROM {$t} WHERE option_name = ?" ); if ( ! $stmt ) { return false; } $name = (string) $option_name; $stmt->bind_param( 's', $name ); $ok = $stmt->execute(); $stmt->close(); return (bool) $ok; } /** * user_login de los administradores (lectura directa en BD, tras importar). */ public function get_admin_logins( $prefix ) { global $wpdb; $mysqli = $this->get_mysqli(); $p = $mysqli->real_escape_string( $prefix ); // Las tablas de usuarios que usa este sitio (con CUSTOM_USER_TABLE, las de otro). $qi = function ( $t ) { return self::quote_identifier( $t ); }; $users = $qi( ! empty( $wpdb->users ) ? $wpdb->users : $prefix . 'users' ); $meta = $qi( ! empty( $wpdb->usermeta ) ? $wpdb->usermeta : $prefix . 'usermeta' ); $sql = "SELECT u.user_login FROM {$users} u INNER JOIN {$meta} m ON m.user_id = u.ID" . " WHERE m.meta_key = '{$p}capabilities' AND m.meta_value LIKE '%\"administrator\"%' ORDER BY u.ID"; $out = array(); $res = $mysqli->query( $sql ); if ( $res ) { while ( $r = $res->fetch_row() ) { $out[] = $r[0]; } $res->free(); } return $out; } /** * str_replace en dos pasadas con marcadores: cada aparicion se sustituye UNA vez, aunque * el valor nuevo contenga al antiguo (p. ej. http://a -> http://a:8080 o http://a/staging). */ private function replace_once( $search, $replace, $subject ) { if ( $subject === '' ) { return $subject; } $tokens = array(); foreach ( $search as $i => $s ) { $tokens[ $i ] = "\x00OMNI\x01" . $i . "\x01\x00"; } return str_replace( $tokens, $replace, str_replace( $search, $tokens, $subject ) ); } /** * Patron LIKE '%...%' con los comodines (\ % _) escapados. Sin esto, las variantes con '\' * (URLs escapadas en JSON: http:\/\/...) nunca casaban y esas filas no se actualizaban. */ private function like_contains( $s ) { $mysqli = $this->get_mysqli(); return "'%" . $mysqli->real_escape_string( addcslashes( $s, '\\%_' ) ) . "%'"; } private function get_text_columns( $table ) { $mysqli = $this->get_mysqli(); $res = $mysqli->query( sprintf( "SELECT COLUMN_NAME, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' AND DATA_TYPE IN ('varchar','text','longtext','mediumtext','tinytext','char')", $mysqli->real_escape_string( DB_NAME ), $mysqli->real_escape_string( $table ) ) ); $cols = array(); if ( $res ) { while ( $r = $res->fetch_row() ) { // Las columnas generadas (VIRTUAL / STORED) no se pueden actualizar: MySQL las // recalcula solas. Incluirlas hacia fallar el UPDATE de la fila entera. if ( preg_match( '/\b(?:VIRTUAL|STORED|PERSISTENT)\b/i', (string) $r[1] ) ) { continue; } $cols[] = $r[0]; } $res->free(); } return $cols; } /** * Tablas sin clave primaria: valor a valor y con el reemplazo seguro para datos serializados * (antes REPLACE() en SQL, que rompe los prefijos s:N: de PHP). Por cada columna, la lista de * valores distintos que contienen una URL antigua se guarda primero en una tabla de trabajo * (clave h = MD5 del valor) y se recorre con el mismo presupuesto de tiempo que las demas * tablas. Antes se buscaba con LIKE y se paginaba por el valor, todo en una peticion: * - una tabla grande no terminaba nunca donde el limite de tiempo es fijo, y el reintento * volvia a buscar con LIKE: si la URL nueva contiene a la antigua, los valores ya reescritos * seguian casando y se reescribian dos veces; * - ORDER BY compara solo max_sort_length bytes: valores de mas de ~512 caracteres que * empezaban igual se saltaban. h tiene longitud fija, y las variantes que junta una colacion * _ci (mayusculas, acentos) son filas distintas, cada una con su UPDATE ... = BINARY. * El progreso queda en la propia lista (done). En n se anota el MD5 del valor nuevo antes de * escribirlo: un valor de la lista igual a algo ya reescrito en esta pasada no se vuelve a * tocar (sus filas son las reescritas), tambien tras un corte. Con InnoDB la tabla y la lista * cambian en la misma transaccion (de 50 en 50 valores): un corte deshace las dos a la vez. * Las listas terminan en _omnitmp (get_tables() no las cuenta: los indices de tabla guardados * entre peticiones no cambian) y se borran al acabar la pasada, la importacion o el deshacer. * Devuelve null con la tabla hecha, o el cursor donde seguir: array( 'nopk' => columna ). */ private function replace_in_table_without_pk( $table, $columns, $search, $replace, $pass, $cursor, $late ) { $mysqli = $this->get_mysqli(); $cs = $this->get_charset(); if ( ! preg_match( '/^\w+$/', $cs ) ) { $cs = 'utf8mb4'; } $qt = self::quote_identifier( $table ); $from = ( is_array( $cursor ) && isset( $cursor['nopk'] ) ) ? max( 0, (int) $cursor['nopk'] ) : 0; $res = $mysqli->query( sprintf( "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '%s'", $mysqli->real_escape_string( $table ) ) ); $row = $res ? $res->fetch_row() : null; if ( $res ) { $res->free(); } $engine = $row ? strtolower( (string) $row[0] ) : ''; $tx = ( 'innodb' === $engine ); // Juego de caracteres de cada columna: en las que no son UTF-8 (latin1, cp1251...) los // bytes guardados no son los del valor leido (llega en utf8mb4) y `col = BINARY 'valor'` no // casaba nunca con un acento: esas filas se quedaban con la URL antigua. Se compara el valor // convertido, solo con conexion utf8mb4 (conversion sin perdidas; si no, como antes). $col_cs = array(); $res = $mysqli->query( sprintf( "SELECT COLUMN_NAME, CHARACTER_SET_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '%s'", $mysqli->real_escape_string( $table ) ) ); if ( $res ) { while ( $r = $res->fetch_row() ) { $col_cs[ (string) $r[0] ] = strtolower( (string) $r[1] ); } $res->free(); } // Con MyISAM (sin transacciones) la lista tambien: cada marca se escribe en su orden y sin // esperar al disco en cada una. $helper = ( 'myisam' === $engine ) ? 'MyISAM' : 'InnoDB'; $fail = function ( $what ) use ( $mysqli, $table, $tx ) { $msg = $mysqli->errno . ': ' . $mysqli->error; // antes del ROLLBACK, que lo borraria if ( $tx ) { $mysqli->query( 'ROLLBACK' ); } // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: 1: table name, 2: step that failed, 3: MySQL error text. */ __( 'The URLs of table %1$s could not be replaced (%2$s, %3$s).', 'omnimigrate' ), $table, $what, $msg ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped }; for ( $ci = $from; $ci < count( $columns ); $ci++ ) { $column = $columns[ $ci ]; $qc = self::quote_identifier( $column ); $likes = array(); foreach ( $search as $s ) { $likes[] = $qc . ' LIKE ' . $this->like_contains( $s ); } $where = '(' . implode( ' OR ', $likes ) . ')'; $work = $this->url_work_name( $pass, $table, $column, 'l' ); $qw = self::quote_identifier( $work ); $ccs = isset( $col_cs[ $column ] ) ? $col_cs[ $column ] : ''; $match = ( 'utf8mb4' === strtolower( $cs ) && $ccs !== '' && ! in_array( $ccs, array( 'utf8mb4', 'utf8mb3', 'utf8' ), true ) ) ? "CONVERT({$qc} USING {$cs})" : $qc; if ( ! $this->table_exists( $work ) ) { // Sin ninguna coincidencia (lo normal) no hace falta lista: nada de esta columna se // ha reescrito ni se reescribira en esta pasada. $any = $mysqli->query( "SELECT 1 FROM {$qt} WHERE {$where} LIMIT 1" ); $hit = ! $any || $any->num_rows > 0; if ( $any ) { $any->free(); } if ( ! $hit ) { continue; } // Se construye con otro nombre y se activa con RENAME (atomico): una lista a medio // construir (peticion cortada) nunca se usa y se rehace entera. Una lista que ya // existe nunca se rehace desde el LIKE. $build = $this->url_work_name( $pass, $table, $column, 'b' ); $qb = self::quote_identifier( $build ); $mysqli->query( "DROP TABLE IF EXISTS {$qb}" ); $ok = $mysqli->query( "CREATE TABLE {$qb} (h CHAR(32) CHARACTER SET ascii NOT NULL, v LONGTEXT CHARACTER SET {$cs} COLLATE {$cs}_bin NOT NULL, n CHAR(32) CHARACTER SET ascii NULL, done TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (h), KEY omni_n (n), KEY omni_done (done, h)) ENGINE={$helper}" ) && $mysqli->query( "INSERT IGNORE INTO {$qb} (h, v) SELECT MD5(CONVERT({$qc} USING {$cs})), CONVERT({$qc} USING {$cs}) FROM {$qt} WHERE {$where}" ) && $mysqli->query( "RENAME TABLE {$qb} TO {$qw}" ); if ( ! $ok ) { $errno = (int) $mysqli->errno; $msg = $errno . ': ' . $mysqli->error; $mysqli->query( "DROP TABLE IF EXISTS {$qb}" ); // Un bloqueo o una conexion perdida es pasajero: se detiene (como en el bucle de // abajo) en vez de seguir sin lista y dar la columna por hecha sin haberla hecho. if ( 1205 === $errno || 1213 === $errno || in_array( $errno, self::CONNECTION_ERRORS, true ) ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: 1: table name, 2: MySQL error text. */ __( 'The URLs of table %1$s could not be replaced (work list, %2$s).', 'omnimigrate' ), $table, $msg ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } // Sin lista (sin permiso para crear tablas, disco lleno): como antes, la // columna entera en esta peticion. $this->replace_column_without_pk( $qt, $qc, $match, $where, $search, $replace ); continue; } if ( $late() ) { return array( 'nopk' => $ci ); } } $batch = 0; $retries = 0; if ( $tx ) { $mysqli->query( 'START TRANSACTION' ); } while ( true ) { $res = $mysqli->query( "SELECT h, v FROM {$qw} WHERE done = 0 ORDER BY h LIMIT 1" ); if ( ! $res ) { $fail( __( 'work list', 'omnimigrate' ) ); } $row = $res->fetch_row(); $res->free(); if ( ! $row ) { break; } $hq = $mysqli->real_escape_string( (string) $row[0] ); $old_value = (string) $row[1]; $new_value = $this->apply_replacements( $old_value, $search, $replace ); if ( $new_value !== $old_value ) { $dup = $mysqli->query( "SELECT 1 FROM {$qw} WHERE n = '" . md5( $old_value ) . "' LIMIT 1" ); if ( ! $dup ) { $fail( __( 'work list', 'omnimigrate' ) ); } $rewritten = $dup->num_rows > 0; $dup->free(); if ( ! $rewritten ) { if ( ! $mysqli->query( "UPDATE {$qw} SET n = '" . md5( $new_value ) . "' WHERE h = '{$hq}'" ) ) { $fail( __( 'work list', 'omnimigrate' ) ); } $ok = $mysqli->query( sprintf( "UPDATE %s SET %s = '%s' WHERE %s = BINARY '%s'", $qt, $qc, $mysqli->real_escape_string( $new_value ), $match, $mysqli->real_escape_string( $old_value ) ) ); if ( ! $ok && $tx && in_array( (int) $mysqli->errno, array( 1205, 1213 ), true ) ) { // Bloqueo pasajero: se deshace la tanda (sus valores siguen pendientes en // la lista) y se repite. $msg = $mysqli->error; $mysqli->query( 'ROLLBACK' ); if ( ++$retries > 5 ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: 1: table name, 2: MySQL error text. */ __( 'The URLs of table %1$s could not be replaced (%2$s).', 'omnimigrate' ), $table, $msg ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } usleep( 200000 ); $mysqli->query( 'START TRANSACTION' ); $batch = 0; continue; } if ( ! $ok ) { // Otro error (p. ej. el valor nuevo no cabe en la columna): como en las // tablas con clave, esas filas se quedan como estaban. $mysqli->query( "UPDATE {$qw} SET n = NULL WHERE h = '{$hq}'" ); } } } if ( ! $mysqli->query( "UPDATE {$qw} SET done = 1 WHERE h = '{$hq}'" ) ) { $fail( __( 'work list', 'omnimigrate' ) ); } if ( $tx && ++$batch >= 50 ) { if ( ! $mysqli->query( 'COMMIT' ) ) { $fail( 'COMMIT' ); } $mysqli->query( 'START TRANSACTION' ); $batch = 0; } if ( $late() ) { if ( $tx && ! $mysqli->query( 'COMMIT' ) ) { $fail( 'COMMIT' ); } return array( 'nopk' => $ci ); } } if ( $tx && ! $mysqli->query( 'COMMIT' ) ) { $fail( 'COMMIT' ); } } return null; } /** * Columna sin lista de trabajo: todos sus valores en esta peticion, de 100 en 100 y por el * MD5 del valor (longitud fija: ORDER BY y la condicion de la pagina siguiente comparan lo * mismo, cosa que con el propio valor no pasaba a partir de max_sort_length). Los valores ya * escritos en esta pasada no se vuelven a tocar. */ private function replace_column_without_pk( $qt, $qc, $match, $where, $search, $replace ) { $mysqli = $this->get_mysqli(); $last = ''; $seen = array(); do { $res = $mysqli->query( "SELECT DISTINCT {$qc}, MD5({$qc}) AS omni_h FROM {$qt} WHERE {$where}" . ( $last !== '' ? " AND MD5({$qc}) > '" . $mysqli->real_escape_string( $last ) . "'" : '' ) . ' ORDER BY omni_h LIMIT 100' ); if ( ! $res ) { break; } $rows = array(); while ( $r = $res->fetch_row() ) { $rows[] = $r; } $res->free(); foreach ( $rows as $r ) { $last = (string) $r[1]; $old_value = $r[0]; if ( $old_value === null || isset( $seen[ md5( $old_value ) ] ) ) { continue; } $new_value = $this->apply_replacements( $old_value, $search, $replace ); if ( $new_value !== $old_value ) { $ok = $mysqli->query( sprintf( "UPDATE %s SET %s = '%s' WHERE %s = BINARY '%s'", $qt, $qc, $mysqli->real_escape_string( $new_value ), $match, $mysqli->real_escape_string( $old_value ) ) ); // Bloqueo o conexion perdida: se detiene en vez de seguir y dar la columna por // hecha (los demas errores, como en las tablas con clave: la fila se queda). $errno = (int) $mysqli->errno; if ( ! $ok && ( 1205 === $errno || 1213 === $errno || in_array( $errno, self::CONNECTION_ERRORS, true ) ) ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Va a una excepcion, no a la pantalla: se devuelve como JSON y el navegador lo inserta con .text(). throw new RuntimeException( sprintf( /* translators: 1: table name, 2: MySQL error number, 3: MySQL error text. */ __( 'The URLs of table %1$s could not be replaced (%2$d: %3$s).', 'omnimigrate' ), $qt, $errno, $mysqli->error ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $seen[ md5( $new_value ) ] = true; } } } while ( count( $rows ) === 100 ); } /** * Nombre de una lista de trabajo del reemplazo de URLs: marca de este sitio (su prefijo: no * se tocan las de otra instalacion de la misma base de datos), de la pasada y de la tabla y * columna. $kind: 'l' lista activa, 'b' en construccion. */ private function url_work_name( $pass, $table, $column, $kind ) { return 'omniur' . $kind . '_' . self::url_work_tag() . substr( md5( (string) $pass ), 0, 6 ) . '_' . substr( md5( $table . "\0" . $column ), 0, 12 ) . '_omnitmp'; } private static function url_work_tag() { return substr( md5( 'omniurl' . strtolower( self::own_prefix() ) ), 0, 6 ); } /** * Borra las listas de trabajo del reemplazo de URLs de este sitio (de cualquier pasada): al * terminar la pasada o la importacion, al deshacer y al validar otra. Devuelve cuantas habia. */ public function drop_url_work_tables() { $mysqli = $this->get_mysqli(); $res = $mysqli->query( "SHOW TABLES LIKE 'omniur%'" ); if ( ! $res ) { return 0; } $re = '/^omniur[lb]_' . self::url_work_tag() . '[0-9a-f]{6}_[0-9a-f]{12}_omnitmp$/'; $drop = array(); while ( $r = $res->fetch_row() ) { if ( preg_match( $re, (string) $r[0] ) ) { $drop[] = (string) $r[0]; } } $res->free(); foreach ( $drop as $t ) { $mysqli->query( 'DROP TABLE IF EXISTS ' . self::quote_identifier( $t ) ); } return count( $drop ); } }