* : Native text-meta key. Duplicate rows and structured values are rejected. * --value= * : New literal text value; an empty string is permitted. * [--post_type=] * : Post type. Default: post. * [--backup-dir=] * : Existing private directory outside ABSPATH. Required with --live. * [--live] * : Apply the reviewed changes. Otherwise print a preview only. */ public function update_meta( $args, $assoc ) { $lock = null; $journal = null; try { if ( ! isset( $assoc['meta_key'] ) || ! array_key_exists( 'value', $assoc ) ) { throw new RuntimeException( 'Specify --meta_key and --value.' ); } $key = (string) $assoc['meta_key']; $value = (string) $assoc['value']; $type = (string) ( $assoc['post_type'] ?? 'post' ); if ( '' === $key || strlen( $key ) > 255 || ! post_type_exists( $type ) ) { throw new RuntimeException( 'Invalid meta key or post type.' ); } if ( strlen( $value ) > 32768 ) { throw new RuntimeException( 'Value exceeds the 32 KiB example limit.' ); } $live = isset( $assoc['live'] ); $dir = $live ? $this->private_dir( $assoc['backup-dir'] ?? '' ) : null; if ( $live ) { $lock = $this->lock( $dir ); } $ids = get_posts( array( 'post_type' => $type, 'post_status' => 'any', 'posts_per_page' => self::LIMIT + 1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); if ( count( $ids ) > self::LIMIT ) { throw new RuntimeException( 'More than 5,000 posts. Partition the job before using this example.' ); } $entries = array(); foreach ( $ids as $id ) { $values = $this->values( $id, $key ); if ( array( $value ) !== $values ) { $entries[] = array( 'id' => (int) $id, 'before' => $values, 'after' => array( $value ) ); } } if ( ! $live ) { foreach ( $entries as $entry ) { WP_CLI::line( wp_json_encode( $entry ) ); } WP_CLI::success( count( $entries ) . ' posts would change. Dry run: no files or values written.' ); return; } if ( ! $entries ) { WP_CLI::success( 'Nothing to change.' ); return; } $snapshot = array( 'version' => 1, 'site' => get_option( 'siteurl' ), 'blog_id' => get_current_blog_id(), 'table_prefix' => $GLOBALS['wpdb']->prefix, 'post_type' => $type, 'meta_key' => $key, 'created_at' => gmdate( 'c' ), 'entries' => $entries, ); $encoded = json_encode( $snapshot, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT ); if ( strlen( $encoded ) > 200000000 ) { throw new RuntimeException( 'Snapshot exceeds the 200 MB example limit; partition the job.' ); } $backup = $this->snapshot( $dir, $encoded ); $journal_path = $backup . '.jsonl'; $journal = $this->new_file( $journal_path ); $this->record( $journal, array( 'event' => 'start', 'backup' => basename( $backup ) ) ); WP_CLI::log( 'Backup: ' . $backup ); WP_CLI::log( 'Journal: ' . $journal_path ); $this->apply_entries( $entries, $key, $journal ); WP_CLI::success( count( $entries ) . ' posts verified at the requested value.' ); } catch ( Throwable $error ) { WP_CLI::error( $error->getMessage() . ' Stop; inspect the private snapshot and journal before retrying.' ); } finally { if ( is_resource( $journal ) ) { fclose( $journal ); } if ( is_resource( $lock ) ) { flock( $lock, LOCK_UN ); fclose( $lock ); } } } /** * Restore a snapshot created by this command. Preview by default. * * ## OPTIONS * * --backup= * : Snapshot in its original private directory. * [--live] * : Restore after reviewing the preview and pausing other writers. */ public function restore( $args, $assoc ) { $lock = null; $journal = null; try { $path = realpath( $assoc['backup'] ?? '' ); if ( ! $path || ! is_file( $path ) || filesize( $path ) > 200000000 ) { throw new RuntimeException( 'Backup must be a regular snapshot file under 200 MB.' ); } $dir = $this->private_dir( dirname( $path ) ); if ( ( fileperms( $path ) & 0077 ) !== 0 ) { throw new RuntimeException( 'Snapshot must not be readable by other users.' ); } $snapshot = json_decode( file_get_contents( $path ), true, 512, JSON_THROW_ON_ERROR ); if ( ( $snapshot['version'] ?? null ) !== 1 || ( $snapshot['site'] ?? null ) !== get_option( 'siteurl' ) || ( $snapshot['blog_id'] ?? null ) !== get_current_blog_id() || ( $snapshot['table_prefix'] ?? null ) !== $GLOBALS['wpdb']->prefix || ! is_string( $snapshot['meta_key'] ?? null ) || '' === $snapshot['meta_key'] || strlen( $snapshot['meta_key'] ) > 255 || ! is_string( $snapshot['post_type'] ?? null ) || ! post_type_exists( $snapshot['post_type'] ) || ! is_array( $snapshot['entries'] ?? null ) || ! array_is_list( $snapshot['entries'] ) || count( $snapshot['entries'] ) > self::LIMIT ) { throw new RuntimeException( 'Snapshot does not match this site or supported format.' ); } $live = isset( $assoc['live'] ); if ( $live ) { $lock = $this->lock( $dir ); } $entries = array(); $seen = array(); foreach ( $snapshot['entries'] as $entry ) { $id = $entry['id'] ?? null; if ( ! is_int( $id ) || $id < 1 || isset( $seen[$id] ) || get_post_type( $id ) !== $snapshot['post_type'] ) { throw new RuntimeException( 'Snapshot has a duplicate, missing or mismatched post.' ); } $seen[$id] = true; foreach ( array( 'before', 'after' ) as $field ) { if ( ! isset( $entry[$field] ) || ! is_array( $entry[$field] ) || ! array_is_list( $entry[$field] ) || count( $entry[$field] ) > 1 || ( 'after' === $field && count( $entry[$field] ) !== 1 ) || array_filter( $entry[$field], static fn( $v ) => ! is_string( $v ) || strlen( $v ) > 32768 ) ) { throw new RuntimeException( 'Snapshot contains unsupported meta values.' ); } } $current = $this->values( $id, $snapshot['meta_key'] ); if ( $current === $entry['before'] ) { continue; } if ( $current !== $entry['after'] ) { throw new RuntimeException( "Post {$id} changed after the batch; resolve it manually." ); } $entries[] = array( 'id' => $id, 'before' => $current, 'after' => $entry['before'] ); } if ( ! $live ) { foreach ( $entries as $entry ) { WP_CLI::line( wp_json_encode( $entry ) ); } WP_CLI::success( count( $entries ) . ' posts would be restored. No writes.' ); return; } if ( ! $entries ) { WP_CLI::success( 'Snapshot already restored.' ); return; } $journal_path = $dir . '/te-restore-' . bin2hex( random_bytes( 16 ) ) . '.jsonl'; $journal = $this->new_file( $journal_path ); WP_CLI::log( 'Restore journal: ' . $journal_path ); $this->record( $journal, array( 'event' => 'restore', 'backup' => basename( $path ) ) ); $this->apply_entries( $entries, $snapshot['meta_key'], $journal ); WP_CLI::success( count( $entries ) . ' posts restored and verified.' ); } catch ( Throwable $error ) { WP_CLI::error( $error->getMessage() . ' Restore may be partial; inspect the journal.' ); } finally { if ( is_resource( $journal ) ) { fclose( $journal ); } if ( is_resource( $lock ) ) { flock( $lock, LOCK_UN ); fclose( $lock ); } } } private function values( $id, $key ) { global $wpdb; // SQL equality follows the table collation; the metadata cache uses // exact keys. Reject aliases before a write can affect another key. $keys = $wpdb->get_col( $wpdb->prepare( "SELECT meta_key FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s", $id, $key ) ); if ( $wpdb->last_error || array_filter( $keys, static fn( $stored ) => $stored !== $key ) ) { throw new RuntimeException( "Post {$id} has an ambiguous meta key or could not be checked." ); } $values = get_post_meta( $id, $key, false ); if ( count( $values ) > 1 || array_filter( $values, static fn( $v ) => ! is_string( $v ) || strlen( $v ) > 32768 ) ) { throw new RuntimeException( "Post {$id} has duplicate, structured or oversized meta; use a field-specific updater." ); } return $values; } private function private_dir( $path ) { $dir = realpath( $path ); $web = realpath( ABSPATH ); if ( ! $dir || ! $web || ! is_dir( $dir ) || ! is_writable( $dir ) || $dir === $web || str_starts_with( $dir, $web . DIRECTORY_SEPARATOR ) || ( fileperms( $dir ) & 0077 ) !== 0 ) { throw new RuntimeException( 'Use an existing writable mode-0700 directory outside ABSPATH and every served directory.' ); } $uploads = wp_get_upload_dir(); foreach ( array( WP_CONTENT_DIR, $uploads['basedir'] ) as $served ) { $base = realpath( $served ); if ( $base && ( $dir === $base || str_starts_with( $dir, $base . DIRECTORY_SEPARATOR ) ) ) { throw new RuntimeException( 'Backup directory must be outside content and uploads, including external paths.' ); } } return $dir; } private function new_file( $path ) { $mask = umask( 0077 ); try { $handle = fopen( $path, 'x+b' ); } finally { umask( $mask ); } if ( false === $handle ) { throw new RuntimeException( 'Could not create private file.' ); } if ( ! chmod( $path, 0600 ) ) { fclose( $handle ); throw new RuntimeException( 'Could not protect private file.' ); } return $handle; } private function lock( $dir ) { // The directory is private and belongs to the operator; this lock only // coordinates this tool. Pause editors, imports and other writers too. $mask = umask( 0077 ); try { $handle = fopen( $dir . '/te-batch.lock', 'c+b' ); } finally { umask( $mask ); } if ( ! $handle || ! flock( $handle, LOCK_EX | LOCK_NB ) ) { if ( is_resource( $handle ) ) { fclose( $handle ); } throw new RuntimeException( 'Another batch holds this directory lock.' ); } return $handle; } private function write_all( $handle, $text ) { $offset = 0; while ( $offset < strlen( $text ) ) { $n = fwrite( $handle, substr( $text, $offset ) ); if ( ! $n ) { throw new RuntimeException( 'Private file write failed.' ); } $offset += $n; } if ( ! fflush( $handle ) || ! fsync( $handle ) ) { throw new RuntimeException( 'Private file flush failed.' ); } } private function snapshot( $dir, $json ) { $path = $dir . '/te-batch-' . bin2hex( random_bytes( 16 ) ) . '.json'; $handle = $this->new_file( $path ); try { $this->write_all( $handle, $json ); if ( ! rewind( $handle ) || stream_get_contents( $handle ) !== $json ) { throw new RuntimeException( 'Snapshot readback did not match; no meta was changed.' ); } } finally { fclose( $handle ); } return $path; } private function record( $journal, $event ) { $this->write_all( $journal, json_encode( $event, JSON_THROW_ON_ERROR ) . "\n" ); } private function apply_entries( $entries, $key, $journal ) { foreach ( $entries as $entry ) { $id = $entry['id']; if ( $this->values( $id, $key ) !== $entry['before'] ) { throw new RuntimeException( "Post {$id} changed since the snapshot. No further writes attempted." ); } $this->record( $journal, array( 'event' => 'attempt', 'entry' => $entry ) ); $result = $entry['after'] ? update_post_meta( $id, wp_slash( $key ), wp_slash( $entry['after'][0] ) ) : delete_post_meta( $id, wp_slash( $key ) ); if ( false === $result || $this->values( $id, $key ) !== $entry['after'] ) { $this->record( $journal, array( 'event' => 'failed', 'id' => $id ) ); throw new RuntimeException( "Post {$id} failed verification; earlier posts may have changed." ); } $this->record( $journal, array( 'event' => 'applied', 'id' => $id ) ); } } } WP_CLI::add_command( 'te-batch', 'TE_Safe_Batch_Command' );