This commit is contained in:
Javier Casares 2026-06-02 19:06:05 +00:00
commit 31be5438e7
7 changed files with 293 additions and 195 deletions

View file

@ -1,5 +1,33 @@
== Changelog == == Changelog ==
= 1.2.0 =
_Release date: 2026-06-02_
**Highlights**
* Media uploads dramatically faster: concurrent S3 uploads + streaming from disk
**Performance**
* Concurrent uploads via AWS CommandPool (default 5, configurable via IDRIVEE2_UPLOAD_CONCURRENCY)
* Files now stream directly from disk (fopen + resource) instead of loading fully into memory
* Removed per-file headObject pre-check (eliminates N extra HTTP round-trips per image)
* Single DB write for upload stats per attachment (replaces one write per file)
* Hook priority lowered from 999 to 10
**Compatibility**
* WordPress: 4.1 - 7.1
* PHP: 8.1 - 8.5
**Tests**
* PHP Coding Standards: 3.13.5 (0 errors)
* WordPress Coding Standards: 3.3.0 (0 violations)
* PHPStan: Level 9, 0 errors
* PHPUnit: 22 tests, 54 assertions
= 1.1.4 = = 1.1.4 =
_Release date: 2026-06-02_ _Release date: 2026-06-02_

View file

@ -5,7 +5,7 @@
* Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload * Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload
* Primary Branch: main * Primary Branch: main
* Description: Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging. * Description: Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.
* Version: 1.1.4 * Version: 1.2.0
* Requires at least: 4.1 * Requires at least: 4.1
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: ROBOTSTXT * Author: ROBOTSTXT
@ -36,7 +36,7 @@ if ( ! defined( 'ABSPATH' ) ) {
* *
* @since 1.1.4 * @since 1.1.4
*/ */
define( 'IDRIVEE2_MEDIA_VERSION', '1.1.4' ); define( 'IDRIVEE2_MEDIA_VERSION', '1.2.0' );
/** /**
* Load Composer autoloader if available. * Load Composer autoloader if available.

View file

@ -198,6 +198,69 @@ class Logger {
$this->track_s3_operation( $operation ); $this->track_s3_operation( $operation );
} }
/**
* Log and track the result of a batch S3 upload.
*
* Performs a single DB write for all uploads in the batch instead of one
* write per file, reducing database load on bulk media imports.
*
* @since 1.2.0
*
* @param string $operation S3 operation name (e.g. 'putObject').
* @param int $success_count Number of files successfully uploaded.
* @param int $fail_count Number of files that failed.
* @return void
*/
public function s3_batch( string $operation, int $success_count, int $fail_count ): void {
if ( $success_count > 0 ) {
$this->track_bulk_s3_operations( $operation, $success_count );
}
if ( $fail_count > 0 ) {
$this->log(
self::LEVEL_ERROR,
sprintf( 'S3 %s batch: %d failure(s)', $operation, $fail_count )
);
}
}
/**
* Increment S3 operation counters by an arbitrary count in a single DB write.
*
* @since 1.2.0
*
* @param string $operation S3 operation name.
* @param int $count Number of operations to add.
* @return void
*/
private function track_bulk_s3_operations( string $operation, int $count ): void {
$option_key = 'idrivee2_s3_operations';
$raw = get_option( $option_key, array() );
/**
* Daily S3 operation counts, keyed by date and operation name.
*
* @var array<string, array<string, int>> $stats
*/
$stats = is_array( $raw ) ? $raw : array();
$today = gmdate( 'Y-m-d' );
if ( ! isset( $stats[ $today ] ) ) {
$stats[ $today ] = array();
}
if ( ! isset( $stats[ $today ][ $operation ] ) ) {
$stats[ $today ][ $operation ] = 0;
}
$stats[ $today ][ $operation ] += $count;
$cutoff_date = gmdate( 'Y-m-d', time() - ( 30 * DAY_IN_SECONDS ) );
foreach ( array_keys( $stats ) as $date ) {
if ( $date < $cutoff_date ) {
unset( $stats[ $date ] );
}
}
update_option( $option_key, $stats );
}
/** /**
* Log an authentication failure. * Log an authentication failure.
* *

View file

@ -19,7 +19,8 @@ if ( ! defined( 'ABSPATH' ) ) {
/** /**
* Media uploader for handling file uploads to iDrivee2. * Media uploader for handling file uploads to iDrivee2.
* *
* Uploads attachment files and all generated sizes to S3, deletes local copies, * 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. * and updates the attachment GUID to point to the S3 URL.
* *
* @since 0.3.0 * @since 0.3.0
@ -69,27 +70,25 @@ class Media_Uploader {
* @return void * @return void
*/ */
public function register(): void { public function register(): void {
// Use wp_update_attachment_metadata with high priority to ensure thumbnails are generated. add_filter( 'wp_update_attachment_metadata', array( $this, 'upload_attachment_to_idrivee2' ), 10, 2 );
// Priority 999 ensures this runs AFTER all thumbnail generation is complete.
add_filter( 'wp_update_attachment_metadata', array( $this, 'upload_attachment_to_idrivee2' ), 999, 2 );
add_action( 'edit_attachment', array( $this, 'handle_edit_attachment' ) ); add_action( 'edit_attachment', array( $this, 'handle_edit_attachment' ) );
// Register cron job for cleaning up local files.
add_action( 'idrivee2_cleanup_local_files', array( $this, 'cleanup_local_files' ) ); add_action( 'idrivee2_cleanup_local_files', array( $this, 'cleanup_local_files' ) );
// Schedule cron if not already scheduled.
if ( ! wp_next_scheduled( 'idrivee2_cleanup_local_files' ) ) { if ( ! wp_next_scheduled( 'idrivee2_cleanup_local_files' ) ) {
wp_schedule_event( time(), 'every_five_minutes', 'idrivee2_cleanup_local_files' ); wp_schedule_event( time(), 'every_five_minutes', 'idrivee2_cleanup_local_files' );
} }
} }
/** /**
* Uploads attachment files to iDrivee2 after sizes are generated. * Upload attachment files to iDrivee2 concurrently after sizes are generated.
* *
* This function pushes the original file and all generated sizes to the * Uses the AWS CommandPool to upload the original file and all generated
* configured S3-compatible host, captures the returned ObjectURL for the * thumbnail sizes in parallel, streaming each file directly from disk.
* original file, deletes the local copies, updates the attachment's GUID, * No headObject pre-check is performed files are assumed to be new.
* and filters the front-end URL to use the S3 ObjectURL. *
* Concurrency defaults to 5 and can be overridden via the
* IDRIVEE2_UPLOAD_CONCURRENCY constant in wp-config.php.
* *
* @since 0.3.0 * @since 0.3.0
* *
@ -98,203 +97,199 @@ class Media_Uploader {
* @return array<string, mixed> Unchanged metadata array. * @return array<string, mixed> Unchanged metadata array.
*/ */
public function upload_attachment_to_idrivee2( array $meta, int $attachment_id ): array { public function upload_attachment_to_idrivee2( array $meta, int $attachment_id ): array {
// Bail if configuration is incomplete.
if ( ! $this->config->is_configured() ) { if ( ! $this->config->is_configured() ) {
return $meta; return $meta;
} }
// Initialize the S3 client.
$client = $this->client_factory->create();
// Build list of files: original + each image size.
$meta_file = isset( $meta['file'] ) && is_string( $meta['file'] ) ? $meta['file'] : ''; $meta_file = isset( $meta['file'] ) && is_string( $meta['file'] ) ? $meta['file'] : '';
if ( '' === $meta_file ) { if ( '' === $meta_file ) {
return $meta; return $meta;
} }
// Build list of files: original + each thumbnail.
$upload_dir = wp_upload_dir(); $upload_dir = wp_upload_dir();
$basedir = $upload_dir['basedir']; $basedir = $upload_dir['basedir'];
$base_path = path_join( $basedir, $meta_file ); $base_path = path_join( $basedir, $meta_file );
$files = array(
'original' => $base_path,
);
$meta_sizes = isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ? $meta['sizes'] : array(); $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 ) { foreach ( $meta_sizes as $size ) {
if ( is_array( $size ) && isset( $size['file'] ) && is_string( $size['file'] ) ) { if ( is_array( $size ) && isset( $size['file'] ) && is_string( $size['file'] ) ) {
$files[ $size['file'] ] = path_join( dirname( $base_path ), $size['file'] ); $files[ $size['file'] ] = path_join( dirname( $base_path ), $size['file'] );
} }
} }
// Log file list for debugging. // Open a stream for each file and build the AWS command list.
$this->logger->info( $client = $this->client_factory->create();
sprintf( 'Preparing to upload %d files to S3', count( $files ) ), $bucket = $this->config->get_bucket();
array( $commands = array();
'attachment_id' => $attachment_id, $key_map = array(); // int index -> file metadata
'original' => basename( $meta_file ), $handles = array(); // int index -> resource
'sizes_count' => count( $meta_sizes ),
)
);
$object_url = ''; $real_basedir = realpath( $basedir );
$s3_base_url = ''; $idx = 0;
$upload_count = 0;
// Load and initialise WP_Filesystem. foreach ( $files as $file_key => $local_path ) {
if ( ! function_exists( 'WP_Filesystem' ) ) { if ( ! file_exists( $local_path ) || ! is_readable( $local_path ) ) {
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 S3 upload' );
return $meta;
}
// Upload each file via WP_Filesystem.
foreach ( $files as $key => $local_path ) {
// Skip if file doesn't exist.
if ( ! $wp_filesystem->exists( $local_path ) ) {
$this->logger->warning(
'File does not exist, skipping upload',
array(
'key' => $key,
'path' => $local_path,
)
);
continue; continue;
} }
// Determine S3 object key. // Security: refuse to read files outside the uploads directory.
$object_key = ( 'original' === $key ) $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 ? $meta_file
: dirname( $meta_file ) . '/' . $key; : dirname( $meta_file ) . '/' . $file_key;
// Check if file already exists in S3. $fh = fopen( $real_path, 'rb' );
try { if ( false === $fh ) {
$exists = $client->headObject( $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( array(
'Bucket' => $this->config->get_bucket(), 'Bucket' => $bucket,
'Key' => $object_key, 'Key' => $object_key,
) 'Body' => $fh,
);
// File exists, skip upload.
$this->logger->info(
sprintf( 'File already exists in S3, skipping: %s', basename( $object_key ) ),
array( 'object_key' => $object_key )
);
continue;
} catch ( \Aws\Exception\AwsException $e ) {
// File doesn't exist (404), proceed with upload.
if ( 404 !== $e->getStatusCode() ) {
// Other error, log and skip.
$this->logger->warning(
sprintf( 'Error checking S3 file existence: %s', basename( $object_key ) ),
array(
'object_key' => $object_key,
'error' => $e->getAwsErrorMessage() ?? '',
)
);
continue;
}
}
// Retrieve file contents via WP_Filesystem.
$content = $wp_filesystem->get_contents( $local_path );
if ( false === $content ) {
$this->logger->warning(
'Failed to read file contents, skipping upload',
array(
'key' => $key,
'path' => $local_path,
'object_key' => $object_key,
)
);
continue;
}
$this->logger->info(
sprintf( 'Uploading to S3: %s', basename( $object_key ) ),
array(
'object_key' => $object_key,
'file_size' => strlen( $content ),
'is_original' => ( 'original' === $key ),
)
);
// Upload to S3 from memory.
try {
$result = $client->putObject(
array(
'Bucket' => $this->config->get_bucket(),
'Key' => $object_key,
'Body' => $content,
'ACL' => 'public-read', 'ACL' => 'public-read',
) )
); );
++$idx;
}
// Log successful upload. if ( empty( $commands ) ) {
$this->logger->s3_operation( 'putObject', true, basename( $object_key ) ); return $meta;
}
// Capture the base URL for constructing CDN URLs. // Run all uploads concurrently.
if ( 'original' === $key ) { $concurrency = defined( 'IDRIVEE2_UPLOAD_CONCURRENCY' )
// Build CDN URL if domain configured, otherwise use S3 URL. ? max( 1, (int) IDRIVEE2_UPLOAD_CONCURRENCY )
if ( $this->config->has_domain() ) { : 5;
$s3_base_url = trailingslashit( $this->config->get_domain() ) . dirname( $meta_file ); $uploaded_paths = array();
} elseif ( isset( $result['ObjectURL'] ) && is_string( $result['ObjectURL'] ) && '' !== $result['ObjectURL'] ) { $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']; $object_url = $result['ObjectURL'];
$s3_base_url = dirname( $result['ObjectURL'] ); $s3_base_url = dirname( $result['ObjectURL'] );
} }
} }
},
++$upload_count; 'rejected' => function (
mixed $reason,
} catch ( \Aws\Exception\AwsException $e ) { int|string $cmd_idx
// Log failed upload. ) use (
$this->logger->s3_operation( 'putObject', false, basename( $object_key ), $e->getAwsErrorMessage() ?? '' ); &$failed_count,
// Continue to next file on error. $key_map,
continue; $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 )
);
},
)
);
// Log upload summary. 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( $this->logger->info(
sprintf( 'Upload complete: %d of %d files uploaded to S3', $upload_count, count( $files ) ), sprintf( 'Batch upload complete: %d/%d files uploaded to S3', $upload_count, $total ),
array( array(
'attachment_id' => $attachment_id, 'attachment_id' => $attachment_id,
'uploaded' => $upload_count, 'concurrency' => $concurrency,
'expected' => count( $files ), 'failed' => $failed_count,
) )
); );
// Update metadata and schedule deletion if files were uploaded.
if ( $upload_count > 0 ) { if ( $upload_count > 0 ) {
update_post_meta( $attachment_id, '_idrivee2_s3_base_url', $s3_base_url ); update_post_meta( $attachment_id, '_idrivee2_s3_base_url', $s3_base_url );
update_post_meta( $attachment_id, '_idrivee2_last_upload', time() ); update_post_meta( $attachment_id, '_idrivee2_last_upload', time() );
$this->schedule_files_for_deletion( $uploaded_paths );
// Schedule local files for deletion after 3 minutes.
$this->schedule_files_for_deletion( array_values( $files ) );
} }
// Preserve relative path in database.
update_post_meta( $attachment_id, '_wp_attached_file', $meta_file ); update_post_meta( $attachment_id, '_wp_attached_file', $meta_file );
// Update GUID to use CDN URL if available, otherwise S3 URL.
if ( $s3_base_url ) { if ( $s3_base_url ) {
$file_name = basename( $meta_file ); if ( $has_domain ) {
if ( $this->config->has_domain() ) { $public_url = trailingslashit( $cdn_domain ) . $meta_file;
// Use CDN domain.
$public_url = trailingslashit( $this->config->get_domain() ) . $meta_file;
} elseif ( $object_url ) { } elseif ( $object_url ) {
// Use S3 ObjectURL.
$public_url = $object_url; $public_url = $object_url;
} else { } else {
// Fallback: construct from base URL. $public_url = $s3_base_url . '/' . basename( $meta_file );
$public_url = $s3_base_url . '/' . $file_name;
} }
// Update the GUID to the public URL.
wp_update_post( wp_update_post(
array( array(
'ID' => $attachment_id, 'ID' => $attachment_id,
@ -322,24 +317,22 @@ class Media_Uploader {
} }
/** /**
* Schedule files for deletion. * Schedule files for deletion via the cron queue.
*
* Adds files to a deletion queue with timestamp. Files will be deleted
* by the cron job after 3 minutes to give WordPress time to process.
* *
* @since 1.0.1 * @since 1.0.1
* *
* @param array<string> $files Array of file paths to delete. * @param array<string> $files Array of local file paths to delete.
* @return void * @return void
*/ */
private function schedule_files_for_deletion( array $files ): void { private function schedule_files_for_deletion( array $files ): void {
$raw_q = get_option( 'idrivee2_deletion_queue', array() ); $raw_q = get_option( 'idrivee2_deletion_queue', array() );
$queue = is_array( $raw_q ) ? $raw_q : array(); $queue = is_array( $raw_q ) ? $raw_q : array();
$now = time();
foreach ( $files as $file_path ) { foreach ( $files as $file_path ) {
$queue[] = array( $queue[] = array(
'path' => $file_path, 'path' => $file_path,
'timestamp' => time(), 'timestamp' => $now,
); );
} }
@ -347,14 +340,11 @@ class Media_Uploader {
} }
/** /**
* Clean up local files that were uploaded to S3. * Clean up local files after successful S3 upload.
* *
* This method runs via WP-Cron every 5 minutes. It deletes files that: * Runs via WP-Cron every 5 minutes. Deletes files that have been in the
* - Were uploaded to S3 successfully * queue for at least 3 minutes, giving WordPress time to serve thumbnails
* - Have been in the queue for at least 3 minutes * from the local copy before removal.
*
* The 3-minute delay ensures WordPress has time to display thumbnails
* in the admin before files are removed.
* *
* @since 1.0.1 * @since 1.0.1
* *
@ -367,7 +357,6 @@ class Media_Uploader {
return; return;
} }
// Load and initialise WP_Filesystem.
if ( ! function_exists( 'WP_Filesystem' ) ) { if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/file.php';
} }
@ -395,46 +384,32 @@ class Media_Uploader {
continue; continue;
} }
// Requeue items with a malformed timestamp rather than deleting immediately.
if ( 0 === $timestamp ) { if ( 0 === $timestamp ) {
$new_queue[] = $item; $new_queue[] = $item;
continue; continue;
} }
// Only delete files older than 3 minutes.
if ( ( $current_time - $timestamp ) < 180 ) { if ( ( $current_time - $timestamp ) < 180 ) {
$new_queue[] = $item; $new_queue[] = $item;
continue; continue;
} }
// Delete the file if it exists.
if ( $wp_filesystem->exists( $file_path ) ) { if ( $wp_filesystem->exists( $file_path ) ) {
$deleted_ok = $wp_filesystem->delete( $file_path ); $deleted_ok = $wp_filesystem->delete( $file_path );
if ( $deleted_ok ) { if ( $deleted_ok ) {
++$deleted; ++$deleted;
$this->logger->info(
'Local file deleted after S3 upload',
array( 'path' => basename( $file_path ) )
);
} else { } else {
// Keep in queue to retry later.
$new_queue[] = $item; $new_queue[] = $item;
$this->logger->warning( $this->logger->warning( 'Failed to delete local file, will retry', array( 'path' => $file_path ) );
'Failed to delete local file, will retry',
array( 'path' => $file_path )
);
} }
} }
// If file doesn't exist, consider it successfully cleaned up (don't re-add to queue).
} }
// Update the queue.
update_option( 'idrivee2_deletion_queue', $new_queue, false ); update_option( 'idrivee2_deletion_queue', $new_queue, false );
// Log cleanup summary if any files were deleted.
if ( $deleted > 0 ) { if ( $deleted > 0 ) {
$this->logger->info( $this->logger->info(
sprintf( 'Cleanup completed: %d files deleted', $deleted ), sprintf( 'Cleanup: %d local files deleted', $deleted ),
array( 'remaining' => count( $new_queue ) ) array( 'remaining' => count( $new_queue ) )
); );
} }

View file

@ -3,9 +3,9 @@ Contributors: robotstxt, javiercasares
Tags: media, upload, s3, cdn, storage, idrivee2, cloud Tags: media, upload, s3, cdn, storage, idrivee2, cloud
Requires at least: 4.1 Requires at least: 4.1
Tested up to: 7.1 Tested up to: 7.1
Stable tag: 1.1.4 Stable tag: 1.2.0
Requires PHP: 8.1 Requires PHP: 8.1
Version: 1.1.4 Version: 1.2.0
License: GPL-3.0-or-later License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -71,6 +71,9 @@ AWS region for the S3 service (e.g., 'us-east-1', 'eu-west-1').
`define('IDRIVEE2_MEDIA_DOMAIN', 'https://cdn.yourdomain.com');` `define('IDRIVEE2_MEDIA_DOMAIN', 'https://cdn.yourdomain.com');`
Custom CDN domain for serving media files. If not defined, files will be served directly from S3 ObjectURL. Custom CDN domain for serving media files. If not defined, files will be served directly from S3 ObjectURL.
`define('IDRIVEE2_UPLOAD_CONCURRENCY', 5);`
Number of simultaneous S3 uploads per attachment. Default: 5. Increase for faster bulk imports on fast connections; decrease if iDrivee2 returns throttling errors. Minimum: 1.
**Security Logging:** **Security Logging:**
To enable security logging, add these constants: To enable security logging, add these constants:
@ -196,6 +199,35 @@ PHP 8.2 or higher is required. The plugin uses strict type declarations and is t
== Changelog == == Changelog ==
= 1.2.0 =
_Release date: 2026-06-02_
**Highlights**
* Media uploads now run concurrently — dramatically faster for bulk imports
* Files stream directly from disk; no full load into memory
**Performance**
* Concurrent S3 uploads via AWS `CommandPool` (default 5 simultaneous, tunable via `IDRIVEE2_UPLOAD_CONCURRENCY` in wp-config.php)
* Files streamed directly from disk using native PHP streams instead of loading entirely into memory — critical for large images
* Removed per-file `headObject` pre-check — files are assumed new (they just came from WordPress thumbnail generation)
* Single DB write for upload statistics per attachment instead of one per file
* Hook priority lowered from 999 to 10 — no unnecessary delay
**Compatibility**
* WordPress: 4.1 - 7.1
* PHP: 8.1 - 8.5
**Tests**
* PHP Coding Standards: 3.13.5 (0 errors)
* WordPress Coding Standards: 3.3.0 (0 violations)
* PHPStan: Level 9, 0 errors
* PHPUnit: 22 tests, 54 assertions
= 1.1.4 = = 1.1.4 =
_Release date: 2026-06-02_ _Release date: 2026-06-02_

View file

@ -1,8 +1,8 @@
{ {
"name": "iDrivee2 Media Upload", "name": "iDrivee2 Media Upload",
"slug": "idrivee2-media-upload", "slug": "idrivee2-media-upload",
"version": "1.1.4", "version": "1.2.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload/releases/download/1.1.4/idrivee2-media-upload-1.1.4.zip", "download_url": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload/releases/download/1.2.0/idrivee2-media-upload-1.2.0.zip",
"requires": "4.1", "requires": "4.1",
"requires_php": "8.1", "requires_php": "8.1",
"tested": "7.1", "tested": "7.1",
@ -11,10 +11,10 @@
"author_profile": "https://www.robotstxt.es/", "author_profile": "https://www.robotstxt.es/",
"homepage": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload", "homepage": "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload",
"description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.", "description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.",
"changelog": "<h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.2-8.5</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>", "changelog": "<h3>1.2.0 - 2026-06-02</h3><ul><li><strong>Performance:</strong> Concurrent S3 uploads via AWS CommandPool (default 5, tunable via IDRIVEE2_UPLOAD_CONCURRENCY)</li><li><strong>Performance:</strong> Stream files directly from disk — no full load into memory</li><li><strong>Performance:</strong> Removed per-file headObject pre-check — single batch DB write for stats</li><li><strong>Changed:</strong> Hook priority lowered from 999 to 10</li></ul><h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.1-8.5, Requires at least 4.1</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>",
"sections": { "sections": {
"description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging. The plugin intercepts WordPress media uploads, pushes files to an S3-compatible bucket, deletes local copies, and rewrites URLs to serve media from the CDN.", "description": "Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging. The plugin intercepts WordPress media uploads, pushes files to an S3-compatible bucket, deletes local copies, and rewrites URLs to serve media from the CDN.",
"changelog": "<h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.2-8.5</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>" "changelog": "<h3>1.2.0 - 2026-06-02</h3><ul><li><strong>Performance:</strong> Concurrent S3 uploads via AWS CommandPool (default 5, tunable via IDRIVEE2_UPLOAD_CONCURRENCY)</li><li><strong>Performance:</strong> Stream files directly from disk — no full load into memory</li><li><strong>Performance:</strong> Removed per-file headObject pre-check — single batch DB write for stats</li><li><strong>Changed:</strong> Hook priority lowered from 999 to 10</li></ul><h3>1.1.4 - 2026-06-02</h3><ul><li><strong>Added:</strong> Full dev tooling: PHPCS, PHPStan level 9, PHPUnit test suite (22 tests)</li><li><strong>Fixed:</strong> WP_Filesystem null guard, type safety on get_option/get_transient, dynamic asset version</li><li><strong>Changed:</strong> Tested up to WordPress 7.1, PHP 8.1-8.5, Requires at least 4.1</li></ul><h3>1.1.3 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Critical namespace issue with Robotstxt_Updater class causing fatal error</li><li><strong>Fixed:</strong> Plugin now loads correctly without PHP fatal errors</li></ul><h3>1.1.2 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Deployment script updated to use PHP 8.2 as platform base for production builds</li><li><strong>Changed:</strong> Now uses composer update --no-dev for consistent dependency resolution</li><li><strong>Improved:</strong> Production packages guarantee PHP 8.2+ compatibility regardless of dev environment</li></ul><h3>1.1.1 - 2026-02-04</h3><ul><li><strong>Fixed:</strong> Deployment script now includes essential files (update.json, robotstxt-updater.php, readme.txt, changelog.txt)</li><li><strong>Improved:</strong> Production packages now contain all files required for automatic updates from Gitea</li></ul><h3>1.1.0 - 2026-02-04</h3><ul><li><strong>Changed:</strong> Added explicit PHP version requirement (>=8.2) to composer.json</li><li><strong>Changed:</strong> Updated update.json with correct plugin information</li><li><strong>Changed:</strong> Fixed Text Domain in robotstxt-updater.php to match plugin slug</li><li><strong>Fixed:</strong> Composer now validates PHP version during dependency installation</li><li><strong>Fixed:</strong> Plugin update system correctly identifies the plugin</li><li><strong>Fixed:</strong> Translations properly loaded for updater error messages</li><li><strong>Improved:</strong> All text domains now consistently use 'idrivee2-media-upload'</li></ul><h3>1.0.0 - 2026-02-03</h3><ul><li><strong>Release:</strong> First stable release</li><li><strong>Feature:</strong> Automatic upload of media files to iDrivee2 (S3-compatible storage)</li><li><strong>Feature:</strong> URL rewriting to serve media from CDN</li><li><strong>Feature:</strong> Local file deletion after successful upload</li><li><strong>Feature:</strong> Admin interface with connection and upload testing</li><li><strong>Security:</strong> Enterprise-grade security with nonce validation</li><li><strong>Architecture:</strong> Class-based modular architecture with dependency injection</li><li><strong>Testing:</strong> PHPUnit test structure and PHPStan static analysis</li><li><strong>Compatibility:</strong> WordPress 6.8+ and PHP 8.2+</li></ul>"
}, },
"banners": { "banners": {
"low": "", "low": "",

View file

@ -3,7 +3,7 @@
'name' => 'robotstxt/idrivee2-media-upload', 'name' => 'robotstxt/idrivee2-media-upload',
'pretty_version' => 'dev-main', 'pretty_version' => 'dev-main',
'version' => 'dev-main', 'version' => 'dev-main',
'reference' => '1772e18b481a48415cd6982f7beceb62945db944', 'reference' => '51ad14453c553b99ed1481fcaf638827864adc1d',
'type' => 'wordpress-plugin', 'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),
@ -121,7 +121,7 @@
'robotstxt/idrivee2-media-upload' => array( 'robotstxt/idrivee2-media-upload' => array(
'pretty_version' => 'dev-main', 'pretty_version' => 'dev-main',
'version' => 'dev-main', 'version' => 'dev-main',
'reference' => '1772e18b481a48415cd6982f7beceb62945db944', 'reference' => '51ad14453c553b99ed1481fcaf638827864adc1d',
'type' => 'wordpress-plugin', 'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),