array(
'type' => 'int',
'default' => 30,
),
);
}
/**
* Field labels, resolved at render time.
*
* @param string $key Field key.
* @return string
*/
public function field_label( $key ) {
if ( 'retain_days' === $key ) {
return __( 'Days to keep the previous version before deleting it', 'adminkeep' );
}
return '';
}
/**
* What switching this on actually does.
*
* @return string[]
*/
public function how_it_works() {
return array(
__( 'Adds a "Replace file" link to each item in your media library.', 'adminkeep' ),
__( 'Upload a file of the same type and the address does not change at all — every page already using it shows the new version immediately, and nothing else has to be edited.', 'adminkeep' ),
__( 'Upload a different type, such as a PNG over a JPG, and the address has to change. You are told how many posts refer to it before anything is written, those references are updated for you, and the old address is redirected to the new one.', 'adminkeep' ),
__( 'Every generated thumbnail size is rebuilt, and the old ones are deleted rather than left behind.', 'adminkeep' ),
__( 'The previous version is kept for a while, so a mistaken replace can be undone.', 'adminkeep' ),
);
}
/**
* Register hooks.
*
* @param array $settings Resolved settings.
* @return void
*/
public function boot( array $settings ) {
$this->settings = $settings;
add_action( 'admin_menu', array( $this, 'register_screen' ) );
add_action( 'admin_post_' . self::ACTION, array( $this, 'handle_submit' ) );
add_action( MediaReplacer::CLEANUP_HOOK, array( $this, 'run_cleanup' ) );
add_action( 'admin_init', array( $this, 'schedule_cleanup' ) );
}
/**
* Declare the row action on the media library.
*
* @param AdminSurface $surface Surface to declare onto.
* @return void
*/
public function register_surface( AdminSurface $surface ) {
$surface->register_row_action(
new RowAction(
array(
'id' => 'adminkeep-replace-media',
'label' => array( $this, 'row_action_label' ),
'post_types' => array( 'attachment' ),
'capability' => 'edit_post',
'url' => array( $this, 'screen_url' ),
)
)
);
}
/**
* Row action label.
*
* @return string
*/
public function row_action_label() {
return __( 'Replace file', 'adminkeep' );
}
/**
* URL of the replace screen for one attachment.
*
* @param \WP_Post $post Attachment.
* @return string
*/
public function screen_url( $post ) {
return admin_url( 'admin.php?page=' . self::PAGE . '&attachment=' . $post->ID );
}
/**
* Register the replace screen, unlinked from any menu.
*
* @return void
*/
public function register_screen() {
/*
* The parent slug hides the screen and must still be non-empty.
*
* An empty parent is the usual way to register a page with no menu entry, but it makes
* get_admin_page_parent() return '', and an empty parent sends get_admin_page_title()
* down the branch that searches only top-level menus — where a hidden submenu page
* cannot be found. The global $title then stays null, and wp-admin/admin-header.php
* calls strip_tags( null ), which PHP 8.1+ deprecates: a red notice above this plugin's
* own screen, on every load.
*
* options.php is a real admin file that is never a top-level menu, so the submenu is
* still never drawn, but the title resolves.
*/
add_submenu_page(
'options.php',
__( 'Replace file', 'adminkeep' ),
__( 'Replace file', 'adminkeep' ),
'upload_files',
self::PAGE,
array( $this, 'render_screen' )
);
}
/**
* Make sure the retained-file cleanup is scheduled.
*
* @return void
*/
public function schedule_cleanup() {
if ( ! wp_next_scheduled( MediaReplacer::CLEANUP_HOOK ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', MediaReplacer::CLEANUP_HOOK );
}
}
/**
* Delete retained files whose window has passed.
*
* @return void
*/
public function run_cleanup() {
( new MediaReplacer() )->cleanup_expired();
}
/**
* The replace form, including the dry-run reference count.
*
* @return void
*/
public function render_screen() {
$attachment_id = isset( $_GET['attachment'] ) ? absint( $_GET['attachment'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen; the mutation is nonce-checked on submit.
$attachment = get_post( $attachment_id );
if ( ! $attachment instanceof \WP_Post || 'attachment' !== $attachment->post_type ) {
wp_die( esc_html__( 'That media item does not exist.', 'adminkeep' ) );
}
if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
wp_die( esc_html__( 'You are not allowed to replace this file.', 'adminkeep' ) );
}
$replacer = new MediaReplacer();
$file = get_attached_file( $attachment_id );
$extension = strtolower( pathinfo( (string) $file, PATHINFO_EXTENSION ) );
$references = $replacer->count_references( $attachment_id );
echo '
';
printf( '
%s%s
', \Adminkeep\Admin\Brand::heading_mark(), esc_html__( 'Replace file', 'adminkeep' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The mark is escaped where it is built.
printf(
'
%s
%s
',
esc_html( get_the_title( $attachment_id ) ),
esc_html( wp_get_attachment_url( $attachment_id ) )
);
printf(
'
%s
',
esc_html(
sprintf(
/* translators: 1: file extension, 2: number of posts referencing the file. */
_n(
'Upload another .%1$s and nothing else changes — the address stays the same and the %2$d post using this file updates by itself. Upload a different file type and the address must change, so that post will be rewritten for you and the old address redirected.',
'Upload another .%1$s and nothing else changes — the address stays the same and the %2$d posts using this file update by themselves. Upload a different file type and the address must change, so those posts will be rewritten for you and the old address redirected.',
$references,
'adminkeep'
),
$extension,
$references
)
)
);
printf( '
';
}
/**
* Perform the replace.
*
* @return void
*/
public function handle_submit() {
$attachment_id = isset( $_POST['attachment'] ) ? absint( $_POST['attachment'] ) : 0;
check_admin_referer( self::ACTION . '_' . $attachment_id );
if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
wp_die( esc_html__( 'You are not allowed to replace this file.', 'adminkeep' ), '', array( 'response' => 403 ) );
}
if ( empty( $_FILES['adminkeep_replacement']['tmp_name'] ) || ! empty( $_FILES['adminkeep_replacement']['error'] ) ) {
wp_die( esc_html__( 'No usable file was uploaded.', 'adminkeep' ), '', array( 'response' => 400 ) );
}
$tmp_name = sanitize_text_field( wp_unslash( $_FILES['adminkeep_replacement']['tmp_name'] ) );
$filename = isset( $_FILES['adminkeep_replacement']['name'] )
? sanitize_file_name( wp_unslash( $_FILES['adminkeep_replacement']['name'] ) )
: '';
// The browser says what it uploaded; that is not evidence. Reject anything whose real
// contents do not match an extension this site allows.
$verified = wp_check_filetype_and_ext( $tmp_name, $filename );
if ( empty( $verified['ext'] ) || empty( $verified['type'] ) ) {
wp_die( esc_html__( 'That file type is not allowed here.', 'adminkeep' ), '', array( 'response' => 400 ) );
}
$result = ( new MediaReplacer() )->replace(
$attachment_id,
$tmp_name,
array(
'filename' => $filename,
'rewrite_content' => ! empty( $_POST['rewrite_content'] ),
'retain_days' => isset( $this->settings['retain_days'] ) ? (int) $this->settings['retain_days'] : 30,
)
);
if ( is_wp_error( $result ) ) {
wp_die(
esc_html( $result->get_error_message() ),
esc_html__( 'Could not replace the file', 'adminkeep' ),
array( 'response' => 400 )
);
}
wp_safe_redirect( add_query_arg( 'message', 1, admin_url( 'upload.php' ) ) );
exit;
}
/**
* Offer replacing a file to the Abilities API.
*
* **The replacement is named as an attachment, not as a path on disk.** The service takes an
* absolute path, because the admin screen hands it the one PHP just wrote from an upload. An
* ability is reachable over the REST API, so accepting a path there would let anyone holding
* edit_post on one attachment publish any file the web server can read at a public URL. A
* caller uploads through the media endpoints core already guards, then names what it uploaded.
*
* @return array>
*/
public function abilities() {
return array(
'replace-media' => array(
'label' => __( 'Replace a media file', 'adminkeep' ),
'description' => __( 'Swaps the file behind an attachment for the file behind another attachment, keeping the original attachment ID. The same file extension keeps the URL byte-identical; a different one leaves a redirect from the old address and rewrites the posts that referenced it. The previous file is retained so the change can be undone.', 'adminkeep' ),
'input_schema' => array(
'type' => 'object',
'properties' => array(
'attachment_id' => array(
'type' => 'integer',
'description' => __( 'The attachment whose file is being replaced. It keeps its ID, title and every reference to it.', 'adminkeep' ),
),
'source_attachment_id' => array(
'type' => 'integer',
'description' => __( 'An attachment holding the replacement file. Upload it first; it is copied, not consumed.', 'adminkeep' ),
),
'rewrite_content' => array(
'type' => 'boolean',
'description' => __( 'When the extension changes, rewrite the posts that reference the old URL. Defaults to true.', 'adminkeep' ),
),
),
'required' => array( 'attachment_id', 'source_attachment_id' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'attachment_id' => array(
'type' => 'integer',
'description' => __( 'The attachment that was replaced.', 'adminkeep' ),
),
'url' => array(
'type' => 'string',
'description' => __( 'Its URL after the replacement — unchanged when the extension matched.', 'adminkeep' ),
),
),
),
'permission_callback' => array( $this, 'ability_can_replace' ),
'execute_callback' => array( $this, 'ability_replace' ),
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => true,
'idempotent' => false,
),
),
),
);
}
/**
* Whether the current user may replace the requested file with the requested one.
*
* Both sides are checked. Reading the source file is as much a privilege as writing the
* target, since its bytes end up at a public URL.
*
* @param array $input Ability input.
* @return bool
*/
public function ability_can_replace( $input = array() ) {
$target = get_post( isset( $input['attachment_id'] ) ? (int) $input['attachment_id'] : 0 );
$source = get_post( isset( $input['source_attachment_id'] ) ? (int) $input['source_attachment_id'] : 0 );
foreach ( array( $target, $source ) as $attachment ) {
if ( ! $attachment instanceof \WP_Post || 'attachment' !== $attachment->post_type ) {
return false;
}
if ( ! current_user_can( 'edit_post', $attachment->ID ) ) {
return false;
}
}
return true;
}
/**
* Replace one attachment's file with another's.
*
* @param array $input Ability input.
* @return array|\WP_Error
*/
public function ability_replace( $input = array() ) {
$attachment_id = isset( $input['attachment_id'] ) ? (int) $input['attachment_id'] : 0;
$source_id = isset( $input['source_attachment_id'] ) ? (int) $input['source_attachment_id'] : 0;
$source_path = get_attached_file( $source_id );
if ( ! $source_path || ! file_exists( $source_path ) ) {
return new \WP_Error(
'adminkeep_replace_source_missing',
__( 'The attachment named as the replacement has no file on disk.', 'adminkeep' )
);
}
$filename = basename( $source_path );
// The source went through the media library, so it has already been checked once. Checking
// again is cheap and keeps this path honest on its own terms.
$verified = wp_check_filetype_and_ext( $source_path, $filename );
if ( empty( $verified['ext'] ) || empty( $verified['type'] ) ) {
return new \WP_Error(
'adminkeep_replace_disallowed_type',
__( 'That file type is not allowed on this site.', 'adminkeep' )
);
}
$replaced = ( new MediaReplacer() )->replace(
$attachment_id,
$source_path,
array(
'filename' => $filename,
'rewrite_content' => ! isset( $input['rewrite_content'] ) || (bool) $input['rewrite_content'],
'retain_days' => isset( $this->settings['retain_days'] ) ? (int) $this->settings['retain_days'] : 30,
)
);
if ( is_wp_error( $replaced ) ) {
return $replaced;
}
return array(
'attachment_id' => $attachment_id,
'url' => (string) wp_get_attachment_url( $attachment_id ),
);
}
}