idrivee2-media-upload/includes/class-media-uploader.php
2026-06-02 19:06:05 +00:00

417 lines
11 KiB
PHP

<?php
/**
* Media Uploader for iDrivee2 Media Upload.
*
* @package iDrivee2Media
* @since 0.3.0
*/
declare(strict_types=1);
namespace iDrivee2Media;
/**
* Prevent direct access to this file.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Media uploader for handling file uploads to iDrivee2.
*
* Uploads attachment files and all generated sizes to S3 concurrently using
* the AWS CommandPool, streams files directly from disk, deletes local copies,
* and updates the attachment GUID to point to the S3 URL.
*
* @since 0.3.0
*/
class Media_Uploader {
/**
* Configuration instance.
*
* @var Config
*/
private $config;
/**
* S3 client factory.
*
* @var S3_Client_Factory
*/
private $client_factory;
/**
* Logger instance.
*
* @var Logger
*/
private $logger;
/**
* Constructor.
*
* @since 0.3.0
*
* @param Config $config Configuration instance.
* @param S3_Client_Factory $client_factory S3 client factory.
* @param Logger $logger Logger instance.
*/
public function __construct( Config $config, S3_Client_Factory $client_factory, Logger $logger ) {
$this->config = $config;
$this->client_factory = $client_factory;
$this->logger = $logger;
}
/**
* Register hooks for media upload handling.
*
* @since 0.3.0
*
* @return void
*/
public function register(): void {
add_filter( 'wp_update_attachment_metadata', array( $this, 'upload_attachment_to_idrivee2' ), 10, 2 );
add_action( 'edit_attachment', array( $this, 'handle_edit_attachment' ) );
add_action( 'idrivee2_cleanup_local_files', array( $this, 'cleanup_local_files' ) );
if ( ! wp_next_scheduled( 'idrivee2_cleanup_local_files' ) ) {
wp_schedule_event( time(), 'every_five_minutes', 'idrivee2_cleanup_local_files' );
}
}
/**
* Upload attachment files to iDrivee2 concurrently after sizes are generated.
*
* Uses the AWS CommandPool to upload the original file and all generated
* thumbnail sizes in parallel, streaming each file directly from disk.
* No headObject pre-check is performed — files are assumed to be new.
*
* Concurrency defaults to 5 and can be overridden via the
* IDRIVEE2_UPLOAD_CONCURRENCY constant in wp-config.php.
*
* @since 0.3.0
*
* @param array<string, mixed> $meta Attachment metadata, including 'file' and 'sizes'.
* @param int $attachment_id Attachment post ID.
* @return array<string, mixed> Unchanged metadata array.
*/
public function upload_attachment_to_idrivee2( array $meta, int $attachment_id ): array {
if ( ! $this->config->is_configured() ) {
return $meta;
}
$meta_file = isset( $meta['file'] ) && is_string( $meta['file'] ) ? $meta['file'] : '';
if ( '' === $meta_file ) {
return $meta;
}
// Build list of files: original + each thumbnail.
$upload_dir = wp_upload_dir();
$basedir = $upload_dir['basedir'];
$base_path = path_join( $basedir, $meta_file );
$meta_sizes = isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ? $meta['sizes'] : array();
/** @var array<string, string> $files */
$files = array( 'original' => $base_path );
foreach ( $meta_sizes as $size ) {
if ( is_array( $size ) && isset( $size['file'] ) && is_string( $size['file'] ) ) {
$files[ $size['file'] ] = path_join( dirname( $base_path ), $size['file'] );
}
}
// Open a stream for each file and build the AWS command list.
$client = $this->client_factory->create();
$bucket = $this->config->get_bucket();
$commands = array();
$key_map = array(); // int index -> file metadata
$handles = array(); // int index -> resource
$real_basedir = realpath( $basedir );
$idx = 0;
foreach ( $files as $file_key => $local_path ) {
if ( ! file_exists( $local_path ) || ! is_readable( $local_path ) ) {
continue;
}
// Security: refuse to read files outside the uploads directory.
$real_path = realpath( $local_path );
if ( false === $real_path || false === $real_basedir
|| 0 !== strpos( $real_path, $real_basedir . DIRECTORY_SEPARATOR ) ) {
$this->logger->warning( 'Refusing upload outside uploads dir', array( 'path' => $local_path ) );
continue;
}
$object_key = ( 'original' === $file_key )
? $meta_file
: dirname( $meta_file ) . '/' . $file_key;
$fh = fopen( $real_path, 'rb' );
if ( false === $fh ) {
$this->logger->warning( 'Cannot open file for upload', array( 'path' => $local_path ) );
continue;
}
$handles[ $idx ] = $fh;
$key_map[ $idx ] = array(
'key' => $file_key,
'local_path' => $local_path,
'object_key' => $object_key,
);
$commands[ $idx ] = $client->getCommand(
'PutObject',
array(
'Bucket' => $bucket,
'Key' => $object_key,
'Body' => $fh,
'ACL' => 'public-read',
)
);
++$idx;
}
if ( empty( $commands ) ) {
return $meta;
}
// Run all uploads concurrently.
$concurrency = defined( 'IDRIVEE2_UPLOAD_CONCURRENCY' )
? max( 1, (int) IDRIVEE2_UPLOAD_CONCURRENCY )
: 5;
$uploaded_paths = array();
$failed_count = 0;
$object_url = '';
$s3_base_url = '';
$has_domain = $this->config->has_domain();
$cdn_domain = $this->config->get_domain();
$logger = $this->logger;
$pool = new \Aws\CommandPool(
$client,
$commands,
array(
'concurrency' => $concurrency,
'fulfilled' => function (
mixed $result,
int|string $cmd_idx
) use (
&$uploaded_paths,
&$object_url,
&$s3_base_url,
$key_map,
$meta_file,
$has_domain,
$cdn_domain
): void {
if ( ! is_int( $cmd_idx ) ) {
return;
}
$info = $key_map[ $cmd_idx ];
$uploaded_paths[] = $info['local_path'];
if ( 'original' === $info['key'] ) {
if ( $has_domain ) {
$s3_base_url = trailingslashit( $cdn_domain ) . dirname( $meta_file );
} elseif ( $result instanceof \Aws\ResultInterface
&& isset( $result['ObjectURL'] )
&& is_string( $result['ObjectURL'] )
&& '' !== $result['ObjectURL']
) {
$object_url = $result['ObjectURL'];
$s3_base_url = dirname( $result['ObjectURL'] );
}
}
},
'rejected' => function (
mixed $reason,
int|string $cmd_idx
) use (
&$failed_count,
$key_map,
$logger
): void {
++$failed_count;
if ( ! is_int( $cmd_idx ) ) {
return;
}
$info = $key_map[ $cmd_idx ];
$error = '';
if ( $reason instanceof \Aws\Exception\AwsException ) {
$error = $reason->getAwsErrorMessage() ?? $reason->getMessage();
} elseif ( $reason instanceof \Throwable ) {
$error = $reason->getMessage();
}
$logger->error(
sprintf( 'S3 upload failed: %s', basename( $info['object_key'] ) ),
array( 'error' => $error )
);
},
)
);
try {
$pool->promise()->wait();
} finally {
// Always close file streams, even if wait() throws.
foreach ( $handles as $fh ) {
fclose( $fh );
}
}
$upload_count = count( $uploaded_paths );
$total = count( $commands );
// Single log entry and single DB write for all uploads.
$this->logger->s3_batch( 'putObject', $upload_count, $failed_count );
$this->logger->info(
sprintf( 'Batch upload complete: %d/%d files uploaded to S3', $upload_count, $total ),
array(
'attachment_id' => $attachment_id,
'concurrency' => $concurrency,
'failed' => $failed_count,
)
);
if ( $upload_count > 0 ) {
update_post_meta( $attachment_id, '_idrivee2_s3_base_url', $s3_base_url );
update_post_meta( $attachment_id, '_idrivee2_last_upload', time() );
$this->schedule_files_for_deletion( $uploaded_paths );
}
update_post_meta( $attachment_id, '_wp_attached_file', $meta_file );
if ( $s3_base_url ) {
if ( $has_domain ) {
$public_url = trailingslashit( $cdn_domain ) . $meta_file;
} elseif ( $object_url ) {
$public_url = $object_url;
} else {
$public_url = $s3_base_url . '/' . basename( $meta_file );
}
wp_update_post(
array(
'ID' => $attachment_id,
'guid' => $public_url,
)
);
}
return $meta;
}
/**
* Handle edit_attachment action as a fallback.
*
* @since 0.3.0
*
* @param int $post_id Attachment post ID.
* @return void
*/
public function handle_edit_attachment( int $post_id ): void {
$meta = wp_get_attachment_metadata( $post_id );
if ( $meta ) {
$this->upload_attachment_to_idrivee2( $meta, $post_id );
}
}
/**
* Schedule files for deletion via the cron queue.
*
* @since 1.0.1
*
* @param array<string> $files Array of local file paths to delete.
* @return void
*/
private function schedule_files_for_deletion( array $files ): void {
$raw_q = get_option( 'idrivee2_deletion_queue', array() );
$queue = is_array( $raw_q ) ? $raw_q : array();
$now = time();
foreach ( $files as $file_path ) {
$queue[] = array(
'path' => $file_path,
'timestamp' => $now,
);
}
update_option( 'idrivee2_deletion_queue', $queue, false );
}
/**
* Clean up local files after successful S3 upload.
*
* Runs via WP-Cron every 5 minutes. Deletes files that have been in the
* queue for at least 3 minutes, giving WordPress time to serve thumbnails
* from the local copy before removal.
*
* @since 1.0.1
*
* @return void
*/
public function cleanup_local_files(): void {
$raw_queue = get_option( 'idrivee2_deletion_queue', array() );
if ( ! is_array( $raw_queue ) || empty( $raw_queue ) ) {
return;
}
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
global $wp_filesystem;
if ( ! ( $wp_filesystem instanceof \WP_Filesystem_Base ) ) {
$this->logger->error( 'WP_Filesystem not available, aborting cleanup' );
return;
}
$current_time = time();
$new_queue = array();
$deleted = 0;
foreach ( $raw_queue as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$file_path = isset( $item['path'] ) && is_string( $item['path'] ) ? $item['path'] : '';
$timestamp = isset( $item['timestamp'] ) && is_int( $item['timestamp'] ) ? $item['timestamp'] : 0;
if ( '' === $file_path ) {
continue;
}
if ( 0 === $timestamp ) {
$new_queue[] = $item;
continue;
}
if ( ( $current_time - $timestamp ) < 180 ) {
$new_queue[] = $item;
continue;
}
if ( $wp_filesystem->exists( $file_path ) ) {
$deleted_ok = $wp_filesystem->delete( $file_path );
if ( $deleted_ok ) {
++$deleted;
} else {
$new_queue[] = $item;
$this->logger->warning( 'Failed to delete local file, will retry', array( 'path' => $file_path ) );
}
}
}
update_option( 'idrivee2_deletion_queue', $new_queue, false );
if ( $deleted > 0 ) {
$this->logger->info(
sprintf( 'Cleanup: %d local files deleted', $deleted ),
array( 'remaining' => count( $new_queue ) )
);
}
}
}