list_objects( array( 'prefix' => $prefix, 'continuation_token' => $token, 'max_keys' => 100, ) ); ?>

' . esc_html( $result->get_error_message() ) . '

'; return; } ?>
get_bucket(), $object['key'] ); ?>
' . esc_html( get_the_title( $id ) . ' (#' . $id . ')' ) . '
'; } } ?>

'r2clouder-files', 'prefix' => $prefix, 'token' => $result['next_token'], ), admin_url( 'upload.php' ) ) ) . '">' . esc_html__( 'Next page', 'devsyed-media-offloader' ) . ''; } ?> 100 ) { wp_die( esc_html__( 'Select between 1 and 100 files.', 'devsyed-media-offloader' ) ); } foreach ( $keys as $key ) { if ( ! is_string( $key ) || is_wp_error( Object_Key::sanitize( $key ) ) ) { wp_die( esc_html__( 'Invalid object key.', 'devsyed-media-offloader' ) ); } } $keys = array_unique( $keys ); $client = Storage::client(); if ( is_wp_error( $client ) ) { wp_die( esc_html( $client->get_error_message() ) ); } $operation = isset( $_POST['operation'] ) && is_string( $_POST['operation'] ) ? sanitize_key( $_POST['operation'] ) : ''; if ( 'zip' === $operation ) { self::zip( $client, array_values( $keys ) ); return; } if ( empty( $_POST['confirm'] ) ) { wp_die( esc_html__( 'Confirm the move or deletion first.', 'devsyed-media-offloader' ) ); } if ( 'delete' === $operation ) { $attachments = array(); $standalone = array(); // Preflight every owner and permission before deleting anything. foreach ( $keys as $key ) { $owners = Managed_Files::owners( $client->get_bucket(), $key ); if ( is_wp_error( $owners ) ) { wp_die( esc_html( $owners->get_error_message() ) ); } if ( ! $owners ) { $standalone[] = $key; } foreach ( $owners as $id ) { if ( empty( $_POST['delete_attachments'] ) || ! current_user_can( 'delete_post', $id ) || 'attachment' !== get_post_type( $id ) ) { wp_die( esc_html__( 'Confirm deletion of entire managed attachments and ensure you can delete each attachment.', 'devsyed-media-offloader' ) ); } $attachments[ $id ] = $id; } } // Refuse shared variants that would leave another attachment broken. foreach ( $attachments as $id ) { foreach ( Offload_Meta::keys( Offload_Meta::get( $id ), true ) as $key ) { $owners = Managed_Files::owners( $client->get_bucket(), $key ); if ( is_wp_error( $owners ) || array_diff( $owners, $attachments ) ) { wp_die( esc_html__( 'An attachment shares cloud files with another attachment. Select all owners before deleting.', 'devsyed-media-offloader' ) ); } } } foreach ( $attachments as $id ) { if ( ! wp_delete_attachment( $id, true ) ) { wp_die( esc_html__( 'WordPress could not delete an attachment. Remaining files were not deleted.', 'devsyed-media-offloader' ) ); } } foreach ( $standalone as $key ) { $result = $client->delete( $key ); if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ) ); } } } elseif ( 'move_attachment' === $operation && count( $keys ) === 1 && $client instanceof S3_Client ) { $source = reset( $keys ); $owners = Managed_Files::owners( $client->get_bucket(), $source ); if ( is_wp_error( $owners ) || count( $owners ) !== 1 || ! in_array( $source, Offload_Meta::keys( Offload_Meta::get( $owners[0] ) ), true ) ) { wp_die( esc_html__( 'Select one current file belonging to a single attachment.', 'devsyed-media-offloader' ) ); } if ( isset( $_POST['folder'] ) && ! is_string( $_POST['folder'] ) ) { wp_die( esc_html__( 'Invalid destination folder.', 'devsyed-media-offloader' ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Object_Key validates the literal folder in move_attachment. $folder = isset( $_POST['folder'] ) && is_string( $_POST['folder'] ) ? wp_unslash( $_POST['folder'] ) : ''; $result = Managed_Files::move_attachment( $owners[0], $client, $folder ); if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ) ); } } elseif ( 'move' === $operation && count( $keys ) === 1 && $client instanceof S3_Client ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated immediately below by Object_Key. $destination = isset( $_POST['destination'] ) && is_string( $_POST['destination'] ) ? wp_unslash( $_POST['destination'] ) : ''; if ( is_wp_error( Object_Key::sanitize( $destination ) ) ) { wp_die( esc_html__( 'Invalid destination.', 'devsyed-media-offloader' ) ); } $exists = $client->exists( $destination ); if ( is_wp_error( $exists ) || $exists ) { wp_die( esc_html__( 'Destination exists or cannot be checked. Nothing was moved.', 'devsyed-media-offloader' ) ); } $source = reset( $keys ); $owners = Managed_Files::owners( $client->get_bucket(), $source ); $destination_owners = Managed_Files::owners( $client->get_bucket(), $destination ); if ( is_wp_error( $owners ) || is_wp_error( $destination_owners ) || $destination_owners || count( $owners ) > 1 ) { wp_die( esc_html__( 'Ownership could not be verified, the source is shared, or the destination belongs to an attachment.', 'devsyed-media-offloader' ) ); } if ( $owners && ! current_user_can( 'edit_post', $owners[0] ) ) { wp_die( esc_html__( 'You cannot edit this attachment.', 'devsyed-media-offloader' ) ); } if ( $owners && ! in_array( $source, Offload_Meta::keys( Offload_Meta::get( $owners[0] ) ), true ) ) { wp_die( esc_html__( 'Retained versions cannot be moved. Move the current attachment file instead.', 'devsyed-media-offloader' ) ); } $result = $client->copy_object( $source, $destination ); if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ) ); } if ( $owners ) { $result = Managed_Files::relocate( $owners[0], $client->get_bucket(), $source, $destination ); if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ) ); } } else { $result = $client->delete( $source ); if ( is_wp_error( $result ) ) { wp_die( esc_html__( 'Copied successfully, but the source could not be deleted. Both files remain.', 'devsyed-media-offloader' ) ); } } } else { wp_die( esc_html__( 'Unsupported operation or select exactly one file to move.', 'devsyed-media-offloader' ) ); } wp_safe_redirect( admin_url( 'upload.php?page=r2clouder-files' ) ); exit; } /** * Streams selected files as a ZIP. * * @param \R2Clouder\Storage\Storage_Adapter $client Storage client. * @param string[] $keys Object keys. * @throws \RuntimeException When archive creation fails (handled locally). */ private static function zip( $client, array $keys ): void { if ( ! class_exists( '\ZipArchive' ) ) { wp_die( esc_html__( 'ZIP downloads require the PHP ZIP extension.', 'devsyed-media-offloader' ) ); } $archive = wp_tempnam( 'clouder.zip' ); $files = array(); $zip = new \ZipArchive(); $opened = false; try { if ( ! $archive || true !== $zip->open( $archive, \ZipArchive::OVERWRITE ) ) { throw new \RuntimeException( 'Could not create ZIP.' ); } $opened = true; $total = 0; foreach ( $keys as $index => $key ) { $file = wp_tempnam( 'clouder-object' ); if ( ! $file ) { throw new \RuntimeException( 'Could not create temporary file.' ); } $files[] = $file; $result = $client->get_to_file( $key, $file ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( $result->get_error_message() ); } clearstatcache( true, $file ); if ( ! is_file( $file ) || ! is_readable( $file ) ) { throw new \RuntimeException( 'Could not read downloaded file.' ); } $total += (int) filesize( $file ); if ( $total > 100 * MB_IN_BYTES ) { throw new \RuntimeException( 'ZIP selection exceeds 100 MB. Select fewer files.' ); } // Flat numbered names prevent traversal and duplicate archive entries. if ( ! $zip->addFile( $file, ( $index + 1 ) . '-' . sanitize_file_name( basename( $key ) ) ) ) { throw new \RuntimeException( 'Could not add file to ZIP.' ); } } $opened = false; if ( ! $zip->close() ) { throw new \RuntimeException( 'Could not finish ZIP.' ); } $opened = false; nocache_headers(); header( 'Content-Type: application/zip' ); header( 'Content-Disposition: attachment; filename="cloud-files.zip"' ); header( 'Content-Length: ' . filesize( $archive ) ); readfile( $archive ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile } catch ( \Throwable $error ) { if ( $opened ) { try { $zip->close(); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Cleanup must preserve the original error even when libzip throws. } catch ( \Throwable $close_error ) { // Preserve the original failure and continue temporary-file cleanup. } } foreach ( $files as $file ) { wp_delete_file( $file ); } if ( $archive ) { wp_delete_file( $archive ); } wp_die( esc_html( $error->getMessage() ) ); } finally { foreach ( $files as $file ) { wp_delete_file( $file ); } if ( $archive ) { wp_delete_file( $archive ); } } exit; } }