> $pairs Old and new URLs. * @return array{updated: int, warnings: string[]}|\WP_Error */ public static function replace( $attachment_id, $pairs ) { unset( $attachment_id ); $pairs = Backup::clean_url_pairs( $pairs ); if ( empty( $pairs ) ) { return array( 'updated' => 0, 'warnings' => array(), ); } $map = self::expand( $pairs ); if ( empty( $map ) ) { return array( 'updated' => 0, 'warnings' => array(), ); } $warnings = array(); $updated = 0; $updated += self::replace_text_columns( $map, $warnings ); $updated += self::replace_meta_table( 'post', $map, $warnings ); $updated += self::replace_meta_table( 'comment', $map, $warnings ); $updated += self::replace_meta_table( 'term', $map, $warnings ); $updated += self::replace_meta_table( 'user', $map, $warnings ); $updated += self::replace_options( $map, $warnings ); return array( 'updated' => $updated, 'warnings' => $warnings, ); } /** * Build specific replacement strings from clean URL pairs. * * Replacing the uploads path covers http, https, and other hostnames. * * @param array> $pairs Clean pairs. * @return array */ private static function expand( $pairs ) { $map = array(); foreach ( $pairs as $pair ) { self::add_pair( $map, $pair['old'], $pair['new'] ); $old_path = self::path_of( $pair['old'] ); $new_path = self::path_of( $pair['new'] ); if ( '' !== $old_path && '' !== $new_path ) { self::add_pair( $map, $old_path, $new_path ); self::add_pair( $map, self::escape_slashes( $old_path ), self::escape_slashes( $new_path ) ); } $old_encoded = self::encoded_url( $pair['old'] ); $new_encoded = self::encoded_url( $pair['new'] ); if ( '' !== $old_encoded && '' !== $new_encoded ) { self::add_pair( $map, $old_encoded, $new_encoded ); $old_encoded_path = self::path_of( $old_encoded ); $new_encoded_path = self::path_of( $new_encoded ); if ( '' !== $old_encoded_path && '' !== $new_encoded_path ) { self::add_pair( $map, $old_encoded_path, $new_encoded_path ); } } } uksort( $map, static function ( $left, $right ) { return strlen( (string) $right ) <=> strlen( (string) $left ); } ); return $map; } /** * @param array $map Map being built. * @param string $old Old string. * @param string $new New string. * @return void */ private static function add_pair( &$map, $old, $new ) { if ( ! is_string( $old ) || ! is_string( $new ) || $old === $new ) { return; } if ( strlen( $old ) < 16 || strlen( $new ) < 16 || strlen( $old ) > 2048 || strlen( $new ) > 2048 ) { return; } if ( ! self::contains_uploads_marker( $old ) || ! self::contains_uploads_marker( $new ) ) { return; } $map[ $old ] = $new; } /** * @param string $value URL. * @return string */ private static function path_of( $value ) { $path = wp_parse_url( $value, PHP_URL_PATH ); if ( ! is_string( $path ) || strlen( $path ) < 16 || false === strpos( $path, '/uploads/' ) ) { return ''; } return $path; } /** * @param string $value URL or path. * @return string */ private static function escape_slashes( $value ) { return str_replace( '/', '\\/', $value ); } /** * @param string $url Absolute URL. * @return string Empty when encoding does not change the URL. */ private static function encoded_url( $url ) { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['path'] ) || empty( $parts['host'] ) ) { return ''; } $segments = explode( '/', (string) $parts['path'] ); $encoded = array(); foreach ( $segments as $segment ) { $encoded[] = rawurlencode( rawurldecode( $segment ) ); } $path = implode( '/', $encoded ); if ( $path === $parts['path'] ) { return ''; } return $parts['scheme'] . '://' . $parts['host'] . $path; } /** * Replace one string in plain text columns. * * @param array $map Replacements. * @param string[] $warnings Warning list. * @return int */ private static function replace_text_columns( $map, &$warnings ) { global $wpdb; $updated = 0; foreach ( $map as $old => $new ) { $like = '%' . $wpdb->esc_like( $old ) . '%'; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Stored content has to be updated in place. $posts = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_content = REPLACE( post_content, %s, %s ), post_excerpt = REPLACE( post_excerpt, %s, %s ), post_content_filtered = REPLACE( post_content_filtered, %s, %s ) WHERE post_content LIKE %s OR post_excerpt LIKE %s OR post_content_filtered LIKE %s", $old, $new, $old, $new, $old, $new, $like, $like, $like ) ); $comments = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->comments} SET comment_content = REPLACE( comment_content, %s, %s ) WHERE comment_content LIKE %s", $old, $new, $like ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( false === $posts || false === $comments || '' !== $wpdb->last_error ) { $warnings[] = __( 'Some content references could not be updated. The original file was kept so those links still resolve.', 'flipwebp' ); $wpdb->last_error = ''; return $updated; } $updated += (int) $posts + (int) $comments; } return $updated; } /** * Update one meta table without breaking serialized values. * * @param string $type Meta type. * @param array $map Replacements. * @param string[] $warnings Warning list. * @return int */ private static function replace_meta_table( $type, $map, &$warnings ) { global $wpdb; $table = self::meta_table( $type ); if ( '' === $table ) { return 0; } $seen = array(); $updated = 0; $scanned = 0; foreach ( array_keys( $map ) as $needle ) { $last_id = 0; $like = '%' . $wpdb->esc_like( (string) $needle ) . '%'; while ( $scanned < self::ROW_LIMIT ) { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Meta values are rewritten through update_metadata_by_mid. The table name uses the %i placeholder. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT meta_id, meta_key, meta_value FROM %i WHERE meta_id > %d AND meta_value LIKE %s AND CHAR_LENGTH( meta_value ) < %d ORDER BY meta_id ASC LIMIT 100', $table, $last_id, $like, 5 * MB_IN_BYTES ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! is_array( $rows ) || empty( $rows ) ) { break; } foreach ( $rows as $row ) { $last_id = (int) $row->meta_id; ++$scanned; if ( isset( $seen[ $last_id ] ) || self::skip_meta_key( (string) $row->meta_key ) ) { continue; } $seen[ $last_id ] = true; $replaced = self::replace_stored_value( (string) $row->meta_value, $map ); if ( null === $replaced ) { continue; } if ( is_wp_error( $replaced ) ) { $warnings[] = $replaced->get_error_message(); continue; } update_metadata_by_mid( $type, $last_id, $replaced ); ++$updated; } if ( count( $rows ) < 100 ) { break; } } } if ( $scanned >= self::ROW_LIMIT ) { $warnings[] = __( 'The reference scan reached its safety limit. Clear any page cache and check content that still uses the old image address.', 'flipwebp' ); } return $updated; } /** * @param array $map Replacements. * @param string[] $warnings Warning list. * @return int */ private static function replace_options( $map, &$warnings ) { global $wpdb; $seen = array(); $updated = 0; foreach ( array_keys( $map ) as $needle ) { $last_id = 0; $like = '%' . $wpdb->esc_like( (string) $needle ) . '%'; while ( count( $seen ) < self::ROW_LIMIT ) { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_id, option_name, option_value, autoload FROM {$wpdb->options} WHERE option_id > %d AND option_value LIKE %s AND CHAR_LENGTH( option_value ) < %d AND option_name NOT LIKE %s AND option_name NOT LIKE %s AND option_name NOT IN ( 'cron', 'rewrite_rules', 'flipwebp_settings' ) ORDER BY option_id ASC LIMIT 50", $last_id, $like, 2 * MB_IN_BYTES, $wpdb->esc_like( '_transient_' ) . '%', $wpdb->esc_like( '_site_transient_' ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! is_array( $rows ) || empty( $rows ) ) { break; } foreach ( $rows as $row ) { $last_id = (int) $row->option_id; $name = (string) $row->option_name; if ( isset( $seen[ $name ] ) ) { continue; } $seen[ $name ] = true; $replaced = self::replace_stored_value( (string) $row->option_value, $map ); if ( null === $replaced || is_wp_error( $replaced ) ) { continue; } update_option( $name, $replaced, self::autoload_flag( $row->autoload ) ); ++$updated; } if ( count( $rows ) < 50 ) { break; } } } if ( count( $seen ) >= self::ROW_LIMIT ) { $warnings[] = __( 'The reference scan reached its safety limit. Clear any page cache and check content that still uses the old image address.', 'flipwebp' ); } return $updated; } /** * Whether a stored string contains an uploads path, including JSON-escaped slashes. * * @param string $value Stored text. * @return bool */ private static function contains_uploads_marker( $value ) { return false !== strpos( $value, 'uploads/' ) || false !== strpos( $value, 'uploads\\/' ); } /** * @param string $raw Stored database value. * @param array $map Replacements. * @return mixed|null|\WP_Error Null when nothing changed. */ private static function replace_stored_value( $raw, $map ) { if ( is_serialized( $raw ) ) { if ( self::contains_object( $raw ) ) { return new \WP_Error( 'flipwebp_serialized_object', __( 'A serialized setting that contains objects was left unchanged.', 'flipwebp' ) ); } $data = unserialize( $raw, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Serialized plugin and theme data is converted back through update_metadata_by_mid. Objects are rejected above. if ( false === $data && 'b:0;' !== $raw ) { return null; } $new = self::deep_replace( $data, $map, 0 ); if ( $new === $data ) { return null; } return $new; } $new = self::replace_string( $raw, $map ); if ( $new === $raw ) { return null; } return $new; } /** * @param string $raw Serialized value. * @return bool */ private static function contains_object( $raw ) { return 1 === preg_match( '/(^|[;{])[OC]:\d+:/', $raw ); } /** * @param mixed $data Unserialized value. * @param array $map Replacements. * @param int $depth Recursion depth. * @return mixed */ private static function deep_replace( $data, $map, $depth ) { if ( $depth > 20 ) { return $data; } if ( is_string( $data ) ) { return self::replace_string( $data, $map ); } if ( ! is_array( $data ) ) { return $data; } $replaced = array(); foreach ( $data as $key => $value ) { $new_key = is_string( $key ) ? self::replace_string( $key, $map ) : $key; $replaced[ $new_key ] = self::deep_replace( $value, $map, $depth + 1 ); } return $replaced; } /** * @param string $value Text. * @param array $map Replacements, longest first. * @return string */ private static function replace_string( $value, $map ) { if ( '' === $value || ! self::contains_uploads_marker( $value ) ) { return $value; } foreach ( $map as $old => $new ) { if ( false !== strpos( $value, (string) $old ) ) { $value = str_replace( (string) $old, (string) $new, $value ); } } return $value; } /** * Keep a stored autoload flag inside the values WordPress accepts. * * @param mixed $value Raw options.autoload column. * @return bool|null */ private static function autoload_flag( $value ) { if ( true === $value || false === $value || null === $value ) { return $value; } $value = strtolower( (string) $value ); if ( in_array( $value, array( 'yes', 'on', 'auto-on' ), true ) ) { return true; } if ( in_array( $value, array( 'no', 'off', 'auto-off' ), true ) ) { return false; } return null; } /** * @param string $type Meta type. * @return string */ private static function meta_table( $type ) { global $wpdb; switch ( $type ) { case 'post': return $wpdb->postmeta; case 'comment': return $wpdb->commentmeta; case 'term': return $wpdb->termmeta; case 'user': return $wpdb->usermeta; default: return ''; } } /** * @param string $key Meta key. * @return bool */ private static function skip_meta_key( $key ) { $skip = array( '_wp_attached_file', '_wp_attachment_metadata', '_wp_attachment_backup_sizes', 'session_tokens', 'wp_capabilities', 'wp_user_level', ); if ( in_array( $key, $skip, true ) ) { return true; } return 0 === strpos( $key, '_flipwebp_' ); } }