exists and value. */ private static function fields( int $id ): array { $out = array(); foreach ( self::FIELDS as $key ) { $out[ $key ] = array( 'exists' => metadata_exists( 'post', $id, $key ), 'value' => get_post_meta( $id, $key, true ), ); } return $out; } /** * Write the file fields back as recorded, and check them. * * @param int $id The attachment. * @param array $fields The fields() record. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function write_fields( int $id, array $fields ): void { foreach ( self::FIELDS as $key ) { if ( $fields[ $key ]['exists'] ) { Reviewed_Store::update_post_meta( $id, $key, wp_slash( $fields[ $key ]['value'] ) ); } else { Reviewed_Store::delete_post_meta( $id, $key ); } } if ( self::fields( $id ) !== $fields ) { throw new \RuntimeException( 'media_replace_recovery' ); } clean_post_cache( $id ); } /** * Save the replacement record and check it. * * @param int $id The attachment. * @param array $revision The record. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function save_revision( int $id, array $revision ): void { Reviewed_Store::update_post_meta( $id, self::META, wp_slash( $revision ) ); if ( self::revision( $id ) !== $revision ) { throw new \RuntimeException( 'media_replace_recovery' ); } } /** * Capture the old file family for undo verification; these files are never rewritten. * * @param int $id The attachment. * @param array $source Its source file (Media_Actions::source()). * @return array Each file of the family (the file, its sizes and backup sizes) => SHA-256. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function family( int $id, array $source ): array { $meta = wp_get_attachment_metadata( $id, true ); $paths = array( $source['path'] ); $dir = dirname( $source['path'] ); foreach ( array_merge( (array) ( $meta['sizes'] ?? array() ), (array) get_post_meta( $id, '_wp_attachment_backup_sizes', true ) ) as $size ) { if ( is_array( $size ) && ! empty( $size['file'] ) && is_string( $size['file'] ) && basename( $size['file'] ) === $size['file'] ) { $paths[] = $dir . '/' . $size['file']; } } if ( ! empty( $meta['original_image'] ) && is_string( $meta['original_image'] ) && basename( $meta['original_image'] ) === $meta['original_image'] ) { $paths[] = $dir . '/' . $meta['original_image']; } $paths = array_unique( $paths ); if ( count( $paths ) > 128 ) { throw new \RuntimeException( 'media_replace_sizes' ); } $out = array(); $bytes = 0; foreach ( $paths as $path ) { $real = Media_Actions::local_file( $path ); $bytes += (int) filesize( $real ); if ( $bytes > 536870912 ) { throw new \RuntimeException( 'media_replace_sizes' ); } $out[ $real ] = hash_file( 'sha256', $real ); } return $out; } /** * Check the old files are still there unchanged before undo uses them. * * @param array $family File => SHA-256 (family()). * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function verify_family( array $family ): void { foreach ( $family as $path => $hash ) { try { $real = Media_Actions::local_file( $path ); } catch ( \Throwable $e ) { throw new \RuntimeException( 'media_replace_old_changed' ); } if ( ! hash_equals( $hash, hash_file( 'sha256', $real ) ) ) { throw new \RuntimeException( 'media_replace_old_changed' ); } } } /** * Keep an uploaded replacement as a private draft, checked to be the attachment's type and within the limits. * * @param int $id The attachment. * @param array $upload The uploaded file ($_FILES entry). * @param bool $confirm The owner's confirmation. * @return array The draft. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function prepare( int $id, array $upload, bool $confirm ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'media_replace_confirm' ); } return Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $upload ) { $revision = self::revision( $id ); if ( in_array( $revision['state'] ?? '', array( 'applying', 'undoing' ), true ) ) { throw new \RuntimeException( 'media_replace_recovery' ); } $source = Media_Actions::source( $id ); Media_Actions::image_info( $source['path'] ); if ( ! isset( $upload['tmp_name'], $upload['name'], $upload['error'] ) || ! is_string( $upload['tmp_name'] ) || ! is_string( $upload['name'] ) || (int) $upload['error'] !== UPLOAD_ERR_OK || ! is_uploaded_file( $upload['tmp_name'] ) ) { throw new \RuntimeException( 'media_action_upload' ); } $info = Media_Actions::image_info( $upload['tmp_name'] ); $type = wp_check_filetype_and_ext( $upload['tmp_name'], $upload['name'] ); if ( $info['mime'] !== $source['mime'] || ( $type['type'] ?? '' ) !== $info['mime'] || empty( $type['ext'] ) ) { throw new \RuntimeException( 'media_replace_type' ); } // WordPress's uploader moves the file into the private media folder; the draft is made from that copy. try { $incoming = Private_Storage::receive_upload( $upload, Media_Actions::directory(), 'upload-' . bin2hex( random_bytes( 16 ) ) . '.' . $type['ext'], array( $type['ext'] => $info['mime'] ) ); } catch ( \RuntimeException $e ) { throw new \RuntimeException( 'media_action_upload' ); } try { return Media_Draft::store( $id, 'replace', $incoming, $source, array( 'operation' => 'same_id_new_file', 'filename' => sanitize_file_name( $upload['name'] ), ) ); } finally { wp_delete_file( $incoming ); } } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Build native sizes without incrementally modifying the live attachment metadata. * * @param int $id The attachment. * @param array $draft The replacement draft. * @return array The attachment's new file fields (its file, its metadata with WordPress's sizes, no backup sizes) for * apply(). * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function stage( int $id, array $draft ): array { require_once ABSPATH . 'wp-admin/includes/image.php'; $src = Media_Draft::path( $draft ); $uploads = wp_upload_dir(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Validated local attachment family is staged and hash-checked before replacement; cleanup targets only generated staging files. if ( ! empty( $uploads['error'] ) || ! is_writable( $uploads['path'] ) ) { throw new \RuntimeException( 'media_action_upload' ); } $base = sanitize_file_name( pathinfo( $draft['source']['path'], PATHINFO_FILENAME ) . '-replacement-' . substr( $draft['id'], 0, 12 ) . '.' . pathinfo( $src, PATHINFO_EXTENSION ) ); $path = trailingslashit( $uploads['path'] ) . wp_unique_filename( $uploads['path'], $base ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Validated local attachment family is staged and hash-checked before replacement; cleanup targets only generated staging files. if ( ! Reviewed_Store::copy( $src, $path ) || hash_file( 'sha256', $path ) !== $draft['image']['sha256'] ) { @Reviewed_Store::unlink( $path ); throw new \RuntimeException( 'media_draft_write' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Validated local attachment family is staged and hash-checked before replacement; cleanup targets only generated staging files. @chmod( $path, 0644 ); $editor = wp_get_image_editor( $path ); if ( is_wp_error( $editor ) ) { throw new \RuntimeException( 'media_replace_backend' ); } $meta = array( 'width' => $draft['image']['width'], 'height' => $draft['image']['height'], 'filesize' => filesize( $path ), 'file' => _wp_relative_upload_path( $path ), 'sizes' => array(), 'image_meta' => wp_read_image_metadata( $path ) ?: array(), ); /** This filter is documented in wp-admin/includes/image.php */ $sizes = apply_filters( 'intermediate_image_sizes_advanced', wp_get_registered_image_subsizes(), $meta, $id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Invoke the existing WordPress intermediate_image_sizes_advanced filter to preserve registered image-size behavior; this is not a new plugin-owned hook. if ( ! is_array( $sizes ) || count( $sizes ) > 64 ) { throw new \RuntimeException( 'media_replace_sizes' ); } $generated = $editor->multi_resize( $sizes ); if ( ! is_array( $generated ) ) { throw new \RuntimeException( 'media_replace_backend' ); } foreach ( $sizes as $name => $size ) { if ( ! is_array( $size ) ) { throw new \RuntimeException( 'media_replace_sizes' ); } $needed = image_resize_dimensions( $meta['width'], $meta['height'], (int) ( $size['width'] ?? 0 ), (int) ( $size['height'] ?? 0 ), $size['crop'] ?? false ); if ( $needed && empty( $generated[ $name ] ) ) { throw new \RuntimeException( 'media_replace_backend' ); } } foreach ( $generated as $size ) { if ( empty( $size['file'] ) || basename( $size['file'] ) !== $size['file'] ) { throw new \RuntimeException( 'media_replace_backend' ); } $info = Media_Actions::image_info( dirname( $path ) . '/' . $size['file'] ); if ( $info['width'] !== (int) $size['width'] || $info['height'] !== (int) $size['height'] ) { throw new \RuntimeException( 'media_replace_backend' ); } } $meta['sizes'] = $generated; return array( '_wp_attached_file' => array( 'exists' => true, 'value' => $meta['file'], ), '_wp_attachment_metadata' => array( 'exists' => true, 'value' => $meta, ), '_wp_attachment_backup_sizes' => array( 'exists' => false, 'value' => '', ), ); } /** * Replace the attachment's file with the draft: a recovery ZIP first, then the new file and sizes under the same * attachment ID; the old files stay for undo. * * @param int $id The attachment. * @param string $token The draft. * @param bool $confirm The owner's confirmation. * @return int The attachment. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function apply( int $id, string $token, bool $confirm ): int { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'media_replace_confirm' ); } return Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $token ) { $draft = Media_Draft::checked( $id, 'replace', $token ); if ( 'saved' === $draft['state'] ) { return $id; } $revision = self::revision( $id ); if ( 'applying' === $draft['state'] && 'applied' === ( $revision['state'] ?? '' ) && $revision['id'] === $token ) { Media_Actions::unchanged( $id, $revision['after_source'] ); $draft['state'] = 'saved'; $draft['result'] = $id; Media_Draft::update( $id, 'replace', $draft ); return $id; } if ( 'applying' === ( $revision['state'] ?? '' ) || 'applying' === $draft['state'] ) { throw new \RuntimeException( 'media_replace_recovery' ); } if ( 'prepared' !== $draft['state'] ) { throw new \RuntimeException( 'media_draft_missing' ); } Media_Actions::unchanged( $id, $draft['source'] ); $lease = Repository::acquire(); if ( ! $lease ) { throw new \RuntimeException( 'worker_busy' ); } try { $before = self::fields( $id ); $family = self::family( $id, $draft['source'] ); $backup = Backup::safety_snapshot( $id ); $after = self::stage( $id, $draft ); Media_Actions::unchanged( $id, $draft['source'] ); self::verify_family( $family ); $revision = array( 'id' => $token, 'state' => 'applying', 'created' => time(), 'backup' => $backup, 'before' => $before, 'after' => $after, 'family' => $family, ); self::save_revision( $id, $revision ); $draft['state'] = 'applying'; Media_Draft::update( $id, 'replace', $draft ); self::write_fields( $id, $after ); Engine::invalidate_attachment( $id, false ); $revision['after_source'] = Media_Actions::source( $id ); $revision['state'] = 'applied'; self::save_revision( $id, $revision ); $draft['state'] = 'saved'; $draft['result'] = $id; Media_Draft::update( $id, 'replace', $draft ); } finally { Repository::release( $lease ); } return $id; } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Return the attachment to its files and metadata before the last replacement (or finish an interrupted replacement * that way). * * @param int $id The attachment. * @param string $token The replacement record. * @param bool $confirm The owner's confirmation. * @param bool $recover True to recover an interrupted replacement. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function undo( int $id, string $token, bool $confirm, bool $recover = false ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'media_replace_confirm' ); } Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $token, $recover ) { $revision = self::revision( $id ); if ( ! $revision || ! hash_equals( (string) $revision['id'], $token ) ) { throw new \RuntimeException( 'media_replace_old_changed' ); } if ( in_array( $revision['state'], array( 'undone', 'recovered' ), true ) ) { return; } if ( $recover || 'undoing' === $revision['state'] ) { if ( ! in_array( $revision['state'], array( 'applying', 'undoing' ), true ) ) { throw new \RuntimeException( 'media_replace_old_changed' ); } $live = self::fields( $id ); foreach ( self::FIELDS as $key ) { if ( $live[ $key ] !== $revision['before'][ $key ] && $live[ $key ] !== $revision['after'][ $key ] ) { throw new \RuntimeException( 'media_replace_old_changed' ); } } } else { if ( 'applied' !== $revision['state'] ) { throw new \RuntimeException( 'media_replace_old_changed' ); } Media_Actions::unchanged( $id, $revision['after_source'] ); } self::verify_family( $revision['family'] ); $lease = Repository::acquire(); if ( ! $lease ) { throw new \RuntimeException( 'worker_busy' ); } try { $revision['state'] = 'undoing'; self::save_revision( $id, $revision ); self::write_fields( $id, $revision['before'] ); Engine::invalidate_attachment( $id, false ); $revision['state'] = $recover ? 'recovered' : 'undone'; self::save_revision( $id, $revision ); $draft = Media_Draft::get( $id, 'replace' ); if ( $draft && $draft['id'] === $token ) { $draft['state'] = $revision['state']; Media_Draft::update( $id, 'replace', $draft ); } } finally { Repository::release( $lease ); } } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } }