as3cf->get_source_type_class( $source_type ); $items_to_move = array(); if ( $this->as3cf->private_prefix_enabled() ) { $new_private_prefix = $this->as3cf->get_setting( 'signed-urls-object-prefix', '' ); } else { $new_private_prefix = ''; } foreach ( $source_ids as $source_id ) { $update = false; $as3cf_item = $class::get_by_source_id( $source_id ); if ( $as3cf_item ) { // Analyze current path to see if it needs changing. $old_prefix = $as3cf_item->normalized_path_dir(); $new_prefix = $this->get_new_public_prefix( $as3cf_item, $old_prefix ); if ( $new_prefix !== $old_prefix ) { $update = true; } // Analyze current private prefix to see if it needs changing. $private_prefix = $as3cf_item->private_prefix(); switch ( $this->should_move_to_new_private_prefix( $as3cf_item, $private_prefix, $new_private_prefix ) ) { case self::MOVE_NO: case self::MOVE_SAME: break; case self::MOVE_NOOP: // If nothing is to be moved to new private prefix, and public isn't being updated, just fix data. if ( false === $update ) { $as3cf_item->set_private_prefix( $new_private_prefix ); $as3cf_item->save(); continue 2; } $private_prefix = $new_private_prefix; break; case self::MOVE_YES: $private_prefix = $new_private_prefix; $update = true; break; } if ( $update ) { $items_to_move[ $source_id ] = array( 'prefix' => $new_prefix, 'private_prefix' => $private_prefix ); } } else { $name = $this->as3cf->get_source_type_name( $source_type ); AS3CF_Error::log( sprintf( 'Move Objects: Offload data for %s item with ID %d could not be found for analysis.', $name, $source_id ) ); } } $this->move_items( $items_to_move, $blog_id, $source_type ); // Whether moved or not, we processed every item. return $source_ids; } /** * Returns new public prefix if required, otherwise returns old prefix. * * phpcs:disable Generic.PHP.DiscourageGoto.Found * * @param Item $as3cf_item * @param string $old_prefix * * @return string */ protected function get_new_public_prefix( Item $as3cf_item, $old_prefix ) { $new_prefix = $as3cf_item->get_new_item_prefix(); // Length changed is simplest indicator. if ( strlen( $old_prefix ) !== strlen( $new_prefix ) ) { goto move_item; } $old_parts = explode( '/', trim( $old_prefix, '/' ) ); $new_parts = explode( '/', trim( $new_prefix, '/' ) ); // Number of path elements changed? if ( count( $old_parts ) !== count( $new_parts ) ) { goto move_item; } // If object versioning is on, don't compare last segment. if ( $this->as3cf->get_setting( 'object-versioning', false ) && $as3cf_item->can_use_object_versioning() ) { $old_parts = array_slice( $old_parts, 0, -1 ); $new_parts = array_slice( $new_parts, 0, -1 ); } // Each element should now be the same. // Simplest way to check here is walk one and check the other by index. // No need to get all fancy! foreach ( $old_parts as $key => $val ) { if ( $new_parts[ $key ] !== $val ) { goto move_item; } } // If here, then prefix does not need to change, regardless of whether private prefix does. // This could be important for mixed public/private thumbnails and external links. // We already know that old and new prefix are the same except for object version, // which is at least still using the same format (length check confirmed that). $new_prefix = $old_prefix; move_item: return $new_prefix; } /** * Should the given item be moved to the new private prefix? * * @param Item $as3cf_item * @param string $old_private_prefix * @param string $new_private_prefix * * @return int One of MOVE_NO, MOVE_YES, MOVE_SAME or MOVE_NOOP. */ protected function should_move_to_new_private_prefix( Item $as3cf_item, $old_private_prefix, $new_private_prefix ) { // Analyze current private prefix to see if it needs changing. if ( $old_private_prefix === $new_private_prefix ) { // Private prefix not changed, nothing to do. return self::MOVE_SAME; } elseif ( ! $as3cf_item->is_private() && ! $as3cf_item->has_private_objects() ) { // Not same, but nothing is to be moved to private prefix, maybe just fix data. return self::MOVE_NOOP; } else { // Private prefix changed, move some private objects. return self::MOVE_YES; } } /** * Move items to new path. * * @param array $items id => ['prefix' => 'new/path/prefix', 'private_prefix' => 'private'] * @param int $blog_id * @param string $source_type * * @throws Exception * * Note: `private_prefix` will be prepended to `prefix` for any object that is private. * `prefix` and `private_prefix` are "directory" paths and can have leading/trailing slashes, they'll be handled. * Both `prefix` and `private_prefix` must be set per item id, but either/both may be empty. */ protected function move_items( $items, $blog_id, $source_type ) { if ( empty( $items ) ) { return; } $bucket = $this->as3cf->get_setting( 'bucket' ); $region = $this->as3cf->get_setting( 'region' ); if ( empty( $bucket ) ) { return; } /** @var Item $class */ $class = $this->as3cf->get_source_type_class( $source_type ); $source_type_name = $this->as3cf->get_source_type_name( $source_type ); $keys = array(); $new_keys = array(); $items_to_move = array(); foreach ( array_keys( $items ) as $source_id ) { /** @var Item $as3cf_item */ $as3cf_item = $class::get_by_source_id( $source_id ); $source_keys = array_unique( $as3cf_item->provider_keys() ); // If the item isn't served by this provider, skip it. if ( ! $as3cf_item->served_by_provider( true ) ) { $error_msg = sprintf( __( '% ID %s is offloaded to a different provider than currently configured', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id ); $this->record_error( $blog_id, $source_type, $source_id, $error_msg ); continue; } // If the prefix isn't set, skip it. if ( ! isset( $items[ $source_id ]['prefix'] ) ) { $error_msg = sprintf( __( 'Prefix not set for % ID %s (this should never happen, please report to support)', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id ); $this->record_error( $blog_id, $source_type, $source_id, $error_msg ); continue; } // If the private_prefix isn't set, skip it. if ( ! isset( $items[ $source_id ]['private_prefix'] ) ) { $error_msg = sprintf( __( 'Private prefix not set for %s ID %s (this should never happen, please report to support)', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id ); $this->record_error( $blog_id, $source_type, $source_id, $error_msg ); continue; } // If the item is offloaded to another bucket, skip it. if ( $as3cf_item->bucket() !== $bucket ) { $error_msg = sprintf( __( '%s ID %s is offloaded to a different bucket than currently configured', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id ); $this->record_error( $blog_id, $source_type, $source_id, $error_msg ); continue; } $updated_item = clone $as3cf_item; $updated_item->set_private_prefix( AS3CF_Utils::trailingslash_prefix( $items[ $source_id ]['private_prefix'] ) ); $updated_item->update_path_prefix( AS3CF_Utils::trailingslash_prefix( $items[ $source_id ]['prefix'] ) ); // TODO: Make sure we're not clobbering another item's path. // Each key found in old paths will be moved to new path as appropriate for access. foreach ( $source_keys as $object_key => $key ) { $new_key = $updated_item->provider_key( $object_key ); // If the old and new key are the same, don't try and move it. if ( $key === $new_key ) { continue; } // We need to record the old and new key so that we can reconcile them later. $keys[ $source_id ][] = $key; $new_keys[ $source_id ][] = $new_key; $args = array( 'Bucket' => $as3cf_item->bucket(), 'Key' => $new_key, 'CopySource' => urlencode( "{$as3cf_item->bucket()}/{$key}" ), ); $acl = $as3cf_item->get_acl_for_object_key( $object_key ); // Only set ACL if actually required, some storage provider and bucket settings disable changing ACL. if ( ! empty( $acl ) ) { $args['ACL'] = $acl; } $args = Storage_Provider::filter_object_meta( $args, $as3cf_item, $object_key ); // Protect against filter use and only set ACL if actually required, some storage provider and bucket settings disable changing ACL. if ( isset( $args['ACL'] ) && empty( $acl ) ) { unset( $args['ACL'] ); } $items_to_move[] = $args; } } // All skipped, abort. if ( empty( $items_to_move ) ) { return; } /* * As there is no such thing as "move" objects in supported providers, and we want to be able to roll-back * an entire item's copies if any fail, we copy, check for failures, and then only delete old keys * which have successfully copied. Any partially copied item have their successful copies deleted * instead so as to not leave orphaned objects either with old or new key prefixes. */ $client = $this->as3cf->get_provider_client( $region, true ); try { $failures = $client->copy_objects( $items_to_move ); } catch ( Exception $e ) { AS3CF_Error::log( $e->getMessage() ); return; } if ( ! empty( $failures ) ) { $keys_to_remove = $this->handle_failed_keys( $keys, $failures, $blog_id, $source_type, $new_keys ); } else { $keys_to_remove = $keys; } // Prepare and batch delete all the redundant keys. $objects_to_delete = array(); foreach ( $keys_to_remove as $source_id => $objects ) { foreach ( $objects as $idx => $object ) { // If key was not moved, don't delete it. if ( in_array( $object, $keys[ $source_id ] ) && in_array( $object, $new_keys[ $source_id ] ) ) { unset( $keys_to_remove[ $source_id ][ $idx ] ); continue; } $objects_to_delete[] = array( 'Key' => $object ); } } if ( ! empty( $objects_to_delete ) ) { try { $client->delete_objects( array( 'Bucket' => $bucket, 'Delete' => array( 'Objects' => $objects_to_delete, ), ) ); } catch ( Exception $e ) { AS3CF_Error::log( $e->getMessage() ); } } $this->update_item_provider_info( $keys, $new_keys, $keys_to_remove, $items, $source_type ); } /** * Handle failed keys. * * @param array $keys id => ['path1', 'path2', ...] * @param array $failures [] => ['Key', 'Message'] * @param int $blog_id * @param string $source_type * @param array $new_keys id => ['path1', 'path2', ...] * * @return array Keys that can be removed, old and new (roll-back) */ protected function handle_failed_keys( $keys, $failures, $blog_id, $source_type, $new_keys ) { foreach ( $failures as $failure ) { foreach ( $new_keys as $source_id => $source_keys ) { $key_id = array_search( $failure['Key'], $source_keys ); if ( false !== $key_id ) { $error_msg = sprintf( __( 'Error moving %1$s to %2$s for item %3$d: %4$s', 'amazon-s3-and-cloudfront' ), $keys[ $source_id ][ $key_id ], $failure['Key'], $source_id, $failure['Message'] ); $this->record_error( $blog_id, $source_type, $source_id, $error_msg ); // Instead of deleting old keys for item, delete new ones (roll-back). $keys[ $source_id ] = $new_keys[ $source_id ]; // Prevent further errors being shown for aborted item. unset( $new_keys[ $source_id ] ); break; } } } return $keys; } /** * Update item provider info. * * @param array $keys id => ['path1', 'path2', ...] * @param array $new_keys id => ['path1', 'path2', ...] * @param array $removed_keys id => ['path1', 'path2', ...] * @param array $items id => ['prefix' => 'new/path/prefix', 'private_prefix' => 'private'] * @param string $source_type */ protected function update_item_provider_info( $keys, $new_keys, $removed_keys, $items, $source_type ) { // There absolutely should be old keys, new keys, some removed/moved, and item prefix data. if ( empty( $keys ) || empty( $new_keys ) || empty( $removed_keys ) || empty( $items ) ) { return; } /** @var Item $class */ $class = $this->as3cf->get_source_type_class( $source_type ); foreach ( $keys as $source_id => $source_keys ) { if ( empty( $items[ $source_id ] ) || empty( $new_keys[ $source_id ] ) || empty( $removed_keys[ $source_id ] ) ) { continue; } // As long as none of the new keys have been removed (roll-back), // then we're all good to update the primary path and private prefix. if ( ! empty( array_intersect( $new_keys[ $source_id ], $removed_keys[ $source_id ] ) ) ) { continue; } $as3cf_item = $class::get_by_source_id( $source_id ); $extra_info = $as3cf_item->extra_info(); $extra_info['private_prefix'] = $items[ $source_id ]['private_prefix']; $as3cf_item->set_extra_info( $extra_info ); $as3cf_item->update_path_prefix( $items[ $source_id ]['prefix'] ); $as3cf_item->save(); } } /** * Get complete notice message. * * @return string */ protected function get_complete_message() { return __( 'Finished moving media files to new paths.', 'amazon-s3-and-cloudfront' ); } /** * Called when background process has been cancelled. */ protected function cancelled() { // Do nothing at the moment. } /** * Called when background process has been paused. */ protected function paused() { // Do nothing at the moment. } /** * Called when background process has been resumed. */ protected function resumed() { // Do nothing at the moment. } /** * Called when background process has completed. */ protected function completed() { // Do nothing at the moment. } }