*/ private static array $in_progress = array(); /** * 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( '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 $meta Attachment metadata, including 'file' and 'sizes'. * @param int $attachment_id Attachment post ID. * @return array Unchanged metadata array. */ public function upload_attachment_to_idrivee2( array $meta, int $attachment_id ): array { // Guard against re-entrant calls: wp_update_post() (used to update the GUID) // fires edit_attachment which would recurse back into this method. if ( isset( self::$in_progress[ $attachment_id ] ) ) { return $meta; } self::$in_progress[ $attachment_id ] = true; try { return $this->do_upload( $meta, $attachment_id ); } finally { unset( self::$in_progress[ $attachment_id ] ); } } /** * Internal upload implementation, called only when not already in progress. * * @since 1.2.0 * * @param array $meta Attachment metadata. * @param int $attachment_id Attachment post ID. * @return array Unchanged metadata array. */ private function do_upload( 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 $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 is public-read by design: this plugin serves media via a CDN // or S3 URL. Files must be publicly accessible for WordPress to // display them. Bucket policies should restrict list access separately. '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 { // The SDK closes streams after upload; only close those still open. foreach ( $handles as $fh ) { if ( is_resource( $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; } /** * Schedule files for deletion via the cron queue. * * @since 1.0.1 * * @param array $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; } $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 ( file_exists( $file_path ) ) { // Use unlink directly: wp_delete_file() returns void on WP < 6.7, // so the boolean return is unreliable across the declared WP range. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read -- cron cleanup, WP_Filesystem unavailable without credentials $deleted_ok = unlink( $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 ) ) ); } } }