Skip to content
Open
209 changes: 184 additions & 25 deletions inc/class-stream-wrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace S3_Uploads;

use Aws\CacheInterface;
use Aws\CommandPool;
use Aws\LruArrayCache;
use Aws\Result;
use Aws\S3\Exception\S3Exception;
Expand Down Expand Up @@ -743,13 +744,12 @@ private function formatKey( string $key ) : string {
}

/**
* Called in response to rename() to rename a file or directory. Currently
* only supports renaming objects.
* Called in response to rename() to rename a file or directory.
*
* @param string $path_from the path to the file to rename
* @param string $path_to the new path to the file
* @param string $path_from the path to the file or directory to rename
* @param string $path_to the new path to the file or directory
*
* @return bool true if file was successfully renamed
* @return bool true if file or directory was successfully renamed
* @link http://www.php.net/manual/en/function.rename.php
*/
public function rename( $path_from, $path_to ) {
Expand All @@ -763,34 +763,158 @@ public function rename( $path_from, $path_to ) {

if ( $partsFrom['Key'] === null || $partsFrom['Key'] === '' || $partsTo['Key'] === null || $partsTo['Key'] === '' ) {
return $this->triggerError(
'The Amazon S3 stream wrapper only '
. 'supports copying objects'
'Renaming a bucket root is not supported. '
. 'You must specify a path in the form of s3://bucket/key'
);
}

return $this->boolCall(
function () use ( $partsFrom, $partsTo ) {
$options = $this->getOptions( true );
// Copy the object and allow overriding default parameters if
// desired, but by default copy metadata
$this->getClient()->copy(
$partsFrom['Bucket'],
$partsFrom['Key'],
$partsTo['Bucket'],
$partsTo['Key'],
isset( $options['acl'] ) ? $options['acl'] : 'private',
$options
);
// Delete the original object
$this->getClient()->deleteObject(
[
'Bucket' => $partsFrom['Bucket'],
'Key' => $partsFrom['Key'],
] + $options
);
return true;
$client = $this->getClient();
$acl = isset( $options['acl'] ) ? $options['acl'] : 'private';

// Normalize keys - remove trailing slashes
$from_key = rtrim( $partsFrom['Key'], '/' );
$to_key = rtrim( $partsTo['Key'], '/' );

$isDirectory = $this->isDirectoryPrefix( $partsFrom['Bucket'], $from_key );

if ( $isDirectory ) {
return $this->renameDirectory( $client, $partsFrom, $partsTo, $from_key, $to_key, $acl, $options );
}

$partsFrom['Key'] = $from_key;
$partsTo['Key'] = $to_key;
return $this->renameFile( $client, $partsFrom, $partsTo, $acl, $options );
}
);
}

/**
* Rename a directory by copying all objects and then deleting originals.
* Uses batch operations for better S3 performance.
*
* @param S3ClientInterface $client S3 client instance
* @param array{Bucket: string, Key: string, ...} $parts_from Source path parts
* @param array{Bucket: string, Key: string, ...} $parts_to Destination path parts
* @param string $from_key Normalized source key
* @param string $to_key Normalized destination key
* @param string $acl ACL for copied objects
* @param array $options Additional S3 options
* @return bool True on success
*/
private function renameDirectory(
S3ClientInterface $client,
array $parts_from,
array $parts_to,
string $from_key,
string $to_key,
string $acl,
array $options
) : bool {
$from_prefix = $from_key . '/';
$to_prefix = $to_key . '/';
$copy_commands = [];
$objects_to_delete = [];
$cache_keys_to_clear = [];

$paginator = $client->getPaginator( 'ListObjectsV2', [
'Bucket' => $parts_from['Bucket'],
'Prefix' => $from_prefix,
] );

foreach ( $paginator as $result ) {
if ( ! isset( $result['Contents'] ) || ! is_array( $result['Contents'] ) ) {
continue;
}

/** @var list<array{Key: string}> $contents */
$contents = $result['Contents'];
foreach ( $contents as $object ) {
/** @var array{Key: string} $object */
/** @var string $old_key */
$old_key = $object['Key'];
/** @var string $new_key */
$new_key = str_replace( $from_prefix, $to_prefix, $old_key );

// Prepare copy command for batch execution
$copy_commands[] = $client->getCommand( 'CopyObject', [
'Bucket' => $parts_to['Bucket'],
'Key' => $new_key,
'CopySource' => $this->encodeCopySource( $parts_from['Bucket'], $old_key ),
'ACL' => $acl,
] + $options );

$objects_to_delete[] = [ 'Key' => $old_key ];
$cache_keys_to_clear[] = "{$this->protocol}://{$parts_from['Bucket']}/{$old_key}";
$cache_keys_to_clear[] = "{$this->protocol}://{$parts_to['Bucket']}/{$new_key}";
}
}

if ( empty( $copy_commands ) ) {
return true;
}

// Execute all copies in parallel using CommandPool
CommandPool::batch( $client, $copy_commands );

// Clear cache for all affected keys
foreach ( $cache_keys_to_clear as $cache_key ) {
$this->clearCacheKey( $cache_key );
}

// Delete all original objects using batch deleteObjects (up to 1000 per request)
$max_delete_batch = 1000;
$delete_batches = array_chunk( $objects_to_delete, $max_delete_batch );

foreach ( $delete_batches as $delete_batch ) {
$client->deleteObjects( [
'Bucket' => $parts_from['Bucket'],
'Delete' => [
'Objects' => $delete_batch,
],
] + $options );
}

return true;
}

/**
* Rename a single file by copying and then deleting the original.
*
* @param S3ClientInterface $client S3 client instance
* @param array{Bucket: string, Key: string, ...} $parts_from Source path parts
* @param array{Bucket: string, Key: string, ...} $parts_to Destination path parts
* @param string $acl ACL for copied object
* @param array $options Additional S3 options
* @return bool True on success
*/
private function renameFile(
S3ClientInterface $client,
array $parts_from,
array $parts_to,
string $acl,
array $options
) : bool {
$client->copy(
$parts_from['Bucket'],
$parts_from['Key'],
$parts_to['Bucket'],
$parts_to['Key'],
$acl,
$options
);

$client->deleteObject( [
'Bucket' => $parts_from['Bucket'],
'Key' => $parts_from['Key'],
] + $options );

$this->clearCacheKey( "{$this->protocol}://{$parts_from['Bucket']}/{$parts_from['Key']}" );
$this->clearCacheKey( "{$this->protocol}://{$parts_to['Bucket']}/{$parts_to['Key']}" );

return true;
}

public function stream_cast( int $cast_as ) : bool {
Expand Down Expand Up @@ -1118,6 +1242,25 @@ private function deleteSubfolder( string $path, array $params ) : bool {
: true;
}

/**
* Check if a key represents a directory prefix (has objects with that prefix).
*
* @param string $bucket The bucket name
* @param string $key The key to check (without trailing slash)
* @return bool True if the key is a directory prefix
*/
private function isDirectoryPrefix( string $bucket, string $key ) : bool {
$prefix = $key . '/';
$result = $this->getClient()->listObjectsV2( [
'Bucket' => $bucket,
'Prefix' => $prefix,
'MaxKeys' => 1,
] );

return ( is_array( $result['Contents'] ) && count( $result['Contents'] ) > 0 )
|| ( is_array( $result['CommonPrefixes'] ) && count( $result['CommonPrefixes'] ) > 0 );
}

/**
* Determine the most appropriate ACL based on a file mode.
*
Expand Down Expand Up @@ -1226,4 +1369,20 @@ private function getSize() {

return $size !== null ? $size : $this->size;
}

/**
* Encode a key for use in CopySource parameter.
* URL-encodes each path segment separately to preserve slashes.
*
* @param string $bucket The bucket name
* @param string $key The object key (may contain spaces, special chars, etc.)
* @return string The encoded CopySource string (bucket/encoded-key)
*/
private function encodeCopySource( string $bucket, string $key ) : string {
$parts = explode( '/', $key );
$encoded_parts = array_map( 'rawurlencode', $parts );
$encoded_key = implode( '/', $encoded_parts );

return "{$bucket}/{$encoded_key}";
}
}
Loading