reference_stem( $attachment_id ); if ( '' === $stem ) { return 0; } global $wpdb; return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status NOT IN ( 'trash', 'auto-draft' ) AND post_type != 'revision' AND post_content LIKE %s", '%' . $wpdb->esc_like( $stem ) . '%' ) ); } /** * Replace the file behind an attachment. * * @param int $attachment_id Attachment to replace. * @param string $new_file_path Absolute path to the incoming file. * @param array $args Optional arguments. { * @type string $filename Original filename, for working out the extension. * @type bool $rewrite_content Rewrite references when the extension changes. Default true. * @type int $retain_days Days to keep the previous file. Default 30. * } * @return true|\WP_Error */ public function replace( $attachment_id, $new_file_path, array $args = array() ) { $args = array_merge( array( 'filename' => '', 'rewrite_content' => true, 'retain_days' => 30, ), $args ); $attachment = get_post( $attachment_id ); if ( ! $attachment instanceof \WP_Post || 'attachment' !== $attachment->post_type ) { return new \WP_Error( 'adminkeep_replace_not_attachment', __( 'That is not a media item.', 'adminkeep' ) ); } if ( ! current_user_can( 'edit_post', $attachment->ID ) ) { return new \WP_Error( 'adminkeep_replace_forbidden', __( 'You are not allowed to replace this file.', 'adminkeep' ) ); } if ( ! is_readable( $new_file_path ) ) { return new \WP_Error( 'adminkeep_replace_unreadable_source', __( 'The new file could not be read.', 'adminkeep' ) ); } $old_path = get_attached_file( $attachment->ID ); if ( ! $old_path || ! file_exists( $old_path ) ) { return new \WP_Error( 'adminkeep_replace_missing_original', __( 'The current file is missing, so there is nothing to replace.', 'adminkeep' ) ); } // The path comes from attachment metadata, which is only data. Every file this class // writes, copies or deletes sits next to it, so it must be inside the uploads directory. if ( ! $this->is_inside_uploads( $old_path ) ) { return new \WP_Error( 'adminkeep_replace_outside_uploads', __( 'This media file is not stored in the uploads folder, so it cannot be replaced.', 'adminkeep' ) ); } $directory = dirname( $old_path ); // Checked before anything is touched. A replace that fails halfway is the outcome this // whole class exists to avoid. // // Deliberately not WP_Filesystem: this is a pre-flight check whose entire job is to // answer "can we write here" without side effects, and WP_Filesystem answers it by // prompting for FTP credentials. Failing cleanly beats a credentials dialog on a media // screen. The writes themselves are copy() into the uploads directory, which is what // core's own upload handling does. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable if ( ! is_writable( $directory ) || ! is_writable( $old_path ) ) { return new \WP_Error( 'adminkeep_replace_unwritable', __( 'That file cannot be written to. Check the folder permissions on your uploads directory.', 'adminkeep' ) ); } $source_name = '' !== $args['filename'] ? $args['filename'] : basename( $new_file_path ); $checked = wp_check_filetype( $source_name ); if ( empty( $checked['ext'] ) || empty( $checked['type'] ) ) { return new \WP_Error( 'adminkeep_replace_bad_type', __( 'That file type is not allowed here.', 'adminkeep' ) ); } $old_extension = strtolower( pathinfo( $old_path, PATHINFO_EXTENSION ) ); $new_extension = strtolower( $checked['ext'] ); $same_extension = ( $old_extension === $new_extension ); $target_path = $same_extension ? $old_path : $directory . '/' . pathinfo( $old_path, PATHINFO_FILENAME ) . '.' . $new_extension; $old_url = wp_get_attachment_url( $attachment->ID ); $old_meta = wp_get_attachment_metadata( $attachment->ID ); $retained = $this->retain( $old_path, (int) $args['retain_days'] ); if ( is_wp_error( $retained ) ) { return $retained; } // The only genuinely destructive step, and the first that can fail. Nothing above this // line has changed the attachment. if ( ! copy( $new_file_path, $target_path ) ) { $this->discard_retained( $retained['path'] ); return new \WP_Error( 'adminkeep_replace_copy_failed', __( 'The new file could not be written into place. Nothing has been changed.', 'adminkeep' ) ); } // Replacing twice would otherwise strand the first backup: the meta is overwritten, so // nothing remembers that file exists and the scheduled cleanup can never find it. Only // one version back is recoverable by design, so the older one goes now. Found by // replacing the same attachment twice on a real site, not by the suite. $this->discard_previous_retained( $attachment->ID, $retained['path'] ); update_post_meta( $attachment->ID, self::RETAINED_META, $retained ); // Stale sizes are computed from the OLD metadata, so this has to happen before the // metadata is regenerated. $this->delete_intermediate_sizes( $old_path, $old_meta ); if ( ! $same_extension ) { // The old primary file is at a different path now, so it is no longer reachable // through the attachment. The retained copy is the recoverable one. wp_delete_file( $old_path ); update_attached_file( $attachment->ID, $target_path ); wp_update_post( array( 'ID' => $attachment->ID, 'post_mime_type' => $checked['type'], ) ); } $this->regenerate_metadata( $attachment->ID, $target_path ); if ( ! $same_extension ) { $this->record_old_url_redirect( $old_url, $attachment->ID ); if ( $args['rewrite_content'] ) { $this->rewrite_references( $attachment->ID, $old_extension, $new_extension ); } } /** * Fires after an attachment's file has been replaced. * * @param int $attachment_id Attachment id. * @param bool $same_extension Whether the URL was preserved. * @param string $old_url The URL the file used to live at. */ do_action( 'adminkeep_media_replaced', $attachment->ID, $same_extension, $old_url ); return true; } /** * A cache-busting URL for showing the new file in the admin. * * Browsers and CDNs cache by URL, and the point of a same-extension replace is that the URL * did not change — so without this the admin shows the old image and the replace looks like * it silently failed. This string is for admin markup only and is never written into post * content, which would defeat preserving the URL in the first place. * * @param int $attachment_id Attachment id. * @return string */ public function preview_url( $attachment_id ) { $url = wp_get_attachment_url( $attachment_id ); $path = get_attached_file( $attachment_id ); if ( ! $url ) { return ''; } $stamp = ( $path && file_exists( $path ) ) ? filemtime( $path ) : time(); return add_query_arg( 'ver', $stamp, $url ); } /** * Restore the retained previous file, if it is still there. * * @param int $attachment_id Attachment id. * @return true|\WP_Error */ public function restore( $attachment_id ) { $retained = get_post_meta( $attachment_id, self::RETAINED_META, true ); if ( ! is_array( $retained ) || empty( $retained['path'] ) || ! file_exists( $retained['path'] ) ) { return new \WP_Error( 'adminkeep_replace_nothing_retained', __( 'There is no previous version left to restore.', 'adminkeep' ) ); } if ( ! current_user_can( 'edit_post', $attachment_id ) ) { return new \WP_Error( 'adminkeep_replace_forbidden', __( 'You are not allowed to change this file.', 'adminkeep' ) ); } $current = get_attached_file( $attachment_id ); $restore = dirname( $current ) . '/' . pathinfo( $current, PATHINFO_FILENAME ) . '.' . pathinfo( $retained['path'], PATHINFO_EXTENSION ); if ( ! $this->is_inside_uploads( $retained['path'] ) || ! $this->is_inside_uploads( $current ) || ! $this->is_inside_uploads( $restore ) ) { return new \WP_Error( 'adminkeep_replace_outside_uploads', __( 'This media file is not stored in the uploads folder, so it cannot be restored.', 'adminkeep' ) ); } if ( ! copy( $retained['path'], $restore ) ) { return new \WP_Error( 'adminkeep_replace_restore_failed', __( 'The previous version could not be written back.', 'adminkeep' ) ); } $this->delete_intermediate_sizes( $current, wp_get_attachment_metadata( $attachment_id ) ); if ( $restore !== $current ) { wp_delete_file( $current ); update_attached_file( $attachment_id, $restore ); } $this->regenerate_metadata( $attachment_id, $restore ); $this->discard_retained( $retained['path'] ); delete_post_meta( $attachment_id, self::RETAINED_META ); return true; } /** * Delete retained files whose window has passed. * * @return int Number of files removed. */ public function cleanup_expired() { $attachments = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => self::RETAINED_META, // phpcs:ignore WordPress.DB.SlowDBQuery 'no_found_rows' => true, ) ); $removed = 0; foreach ( $attachments as $attachment_id ) { $retained = get_post_meta( $attachment_id, self::RETAINED_META, true ); if ( ! is_array( $retained ) || empty( $retained['expires'] ) ) { continue; } if ( (int) $retained['expires'] > time() ) { continue; } $this->discard_retained( $retained['path'] ); delete_post_meta( $attachment_id, self::RETAINED_META ); ++$removed; } return $removed; } /** * Copy the current file aside so a bad replace is recoverable. * * @param string $path Current file path. * @param int $retain_days Days to keep it. * @return array|\WP_Error */ private function retain( $path, $retain_days ) { $retain_days = max( 0, (int) $retain_days ); $directory = dirname( $path ); // wp_unique_filename() rather than a timestamp: two replaces inside the same second // produce an identical timestamped name, and the second copy() then silently overwrites // the first backup. Core's helper checks what is actually on disk. $backup = $directory . '/' . wp_unique_filename( $directory, sprintf( '%s-adminkeep-previous.%s', pathinfo( $path, PATHINFO_FILENAME ), pathinfo( $path, PATHINFO_EXTENSION ) ) ); if ( ! $this->is_inside_uploads( $backup ) || ! copy( $path, $backup ) ) { return new \WP_Error( 'adminkeep_replace_backup_failed', __( 'The current file could not be backed up, so nothing was replaced.', 'adminkeep' ) ); } return array( 'path' => $backup, 'expires' => time() + ( $retain_days * DAY_IN_SECONDS ), ); } /** * Drop the retained file from an earlier replace, if there is one. * * @param int $attachment_id Attachment id. * @param string $keep_path Path of the backup just taken, which must survive. * @return void */ private function discard_previous_retained( $attachment_id, $keep_path ) { $previous = get_post_meta( $attachment_id, self::RETAINED_META, true ); if ( ! is_array( $previous ) || empty( $previous['path'] ) ) { return; } if ( $previous['path'] === $keep_path ) { return; } $this->discard_retained( $previous['path'] ); } /** * Remove a retained file from disk. * * @param string $path Retained file path. * @return void */ private function discard_retained( $path ) { // The path is read back from post meta, so it is checked again rather than trusted. if ( $path && file_exists( $path ) && $this->is_inside_uploads( $path ) ) { wp_delete_file( $path ); } } /** * Whether a path resolves to somewhere inside the uploads directory. * * Resolved with realpath() so "../" segments and symlinks cannot walk out of it. The file * itself need not exist yet (a backup about to be written), but its directory must. * * @param string $path Absolute path. * @return bool */ private function is_inside_uploads( $path ) { if ( ! is_string( $path ) || '' === $path ) { return false; } $uploads = wp_get_upload_dir(); $base = empty( $uploads['basedir'] ) ? false : realpath( $uploads['basedir'] ); $real = file_exists( $path ) ? realpath( $path ) : realpath( dirname( $path ) ); if ( ! $base || ! $real ) { return false; } $base = trailingslashit( wp_normalize_path( $base ) ); $real = wp_normalize_path( $real ); if ( ! file_exists( $path ) ) { $name = basename( $path ); if ( '' === $name || '.' === $name || '..' === $name ) { return false; } $real = trailingslashit( $real ) . $name; } return 0 === strpos( $real, $base ); } /** * Delete the generated size files belonging to a set of metadata. * * @param string $primary_path Path of the primary file. * @param array $meta Attachment metadata. * @return void */ private function delete_intermediate_sizes( $primary_path, $meta ) { if ( ! is_array( $meta ) || empty( $meta['sizes'] ) || ! is_array( $meta['sizes'] ) ) { return; } if ( ! $this->is_inside_uploads( $primary_path ) ) { return; } $directory = dirname( $primary_path ); foreach ( $meta['sizes'] as $size ) { if ( empty( $size['file'] ) ) { continue; } $size_path = $directory . '/' . basename( $size['file'] ); // Never let a crafted metadata entry point outside the attachment's own folder. if ( 0 !== strpos( $size_path, $directory ) || ! file_exists( $size_path ) ) { continue; } wp_delete_file( $size_path ); } } /** * Rebuild the attachment's metadata from the file now on disk. * * @param int $attachment_id Attachment id. * @param string $path Path of the new primary file. * @return void */ private function regenerate_metadata( $attachment_id, $path ) { require_once ABSPATH . 'wp-admin/includes/image.php'; $meta = wp_generate_attachment_metadata( $attachment_id, $path ); if ( is_array( $meta ) ) { wp_update_attachment_metadata( $attachment_id, $meta ); } } /** * Record a redirect from the file's old URL to wherever it lives now. * * @param string $old_url URL the file used to live at. * @param int $attachment_id Attachment id. * @return void */ private function record_old_url_redirect( $old_url, $attachment_id ) { if ( ! $old_url ) { return; } PathRedirectStore::record( PathRedirectResolver::path_of( $old_url ), $attachment_id ); } /** * Point existing references at the new extension. * * Only ever runs when the extension changed, and only rewrites the extension — the path and * filename are identical either side, so this cannot move a reference anywhere unexpected. * Size suffixes such as -300x200 are matched and preserved. * * @param int $attachment_id Attachment id. * @param string $old_extension Extension before. * @param string $new_extension Extension after. * @return int Number of posts rewritten. */ private function rewrite_references( $attachment_id, $old_extension, $new_extension ) { $stem = $this->reference_stem( $attachment_id ); if ( '' === $stem ) { return 0; } global $wpdb; $posts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_status NOT IN ( 'trash', 'auto-draft' ) AND post_type != 'revision' AND post_content LIKE %s", '%' . $wpdb->esc_like( $stem ) . '%' ) ); $pattern = '#(' . preg_quote( $stem, '#' ) . '(?:-\d+x\d+)?)\.' . preg_quote( $old_extension, '#' ) . '#i'; $rewrote = 0; foreach ( $posts as $post ) { $updated = preg_replace( $pattern, '$1.' . $new_extension, $post->post_content ); if ( null === $updated || $updated === $post->post_content ) { continue; } wp_update_post( wp_slash( array( 'ID' => $post->ID, 'post_content' => $updated, ) ) ); ++$rewrote; } return $rewrote; } /** * The upload-relative path stem a reference to this attachment contains. * * "2026/08/hero" — no extension and no size suffix, so it matches every variant. * * @param int $attachment_id Attachment id. * @return string */ private function reference_stem( $attachment_id ) { $relative = get_post_meta( $attachment_id, '_wp_attached_file', true ); if ( ! is_string( $relative ) || '' === $relative ) { return ''; } $directory = ltrim( dirname( $relative ), '.' ); $stem = pathinfo( $relative, PATHINFO_FILENAME ); return '' === $directory ? $stem : trim( $directory, '/' ) . '/' . $stem; } }