This commit is contained in:
Javier Casares 2026-02-04 09:16:10 +00:00
commit 47885eb7c3
6 changed files with 279 additions and 45 deletions

View file

@ -1,9 +1,11 @@
<?php
/**
* Plugin Name: iDrivee2 Media Upload
* Plugin URI: https://github.com/javiercasares/idrivee2-media-upload
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload
* Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload
* Primary Branch: main
* Description: Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.
* Version: 1.0.0
* Version: 1.1.0
* Requires at least: 6.8
* Requires PHP: 8.2
* Author: Javier Casares
@ -59,3 +61,7 @@ require_once __DIR__ . '/includes/class-plugin.php';
* @since 0.3.0
*/
Plugin::get_instance( __FILE__ )->init();
// Initialize ROBOTSTXT updater (auto-configures from plugin headers).
require_once __DIR__ . '/robotstxt-updater.php';
Robotstxt_Updater::init( __FILE__ );

View file

@ -69,9 +69,18 @@ class Media_Uploader {
* @return void
*/
public function register(): void {
add_filter( 'wp_generate_attachment_metadata', array( $this, 'upload_attachment_to_idrivee2' ), 10, 2 );
add_filter( 'wp_update_attachment_metadata', array( $this, 'upload_attachment_to_idrivee2' ), 10, 2 );
// Use wp_update_attachment_metadata with high priority to ensure thumbnails are generated.
// 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' ) );
// Register cron job for cleaning up 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' ) ) {
wp_schedule_event( time(), 'every_five_minutes', 'idrivee2_cleanup_local_files' );
}
}
/**
@ -110,7 +119,19 @@ class Media_Uploader {
}
}
// Log file list for debugging.
$this->logger->info(
sprintf( 'Preparing to upload %d files to S3', count( $files ) ),
array(
'attachment_id' => $attachment_id,
'original' => basename( $meta['file'] ),
'sizes_count' => count( $meta['sizes'] ?? array() ),
)
);
$object_url = '';
$s3_base_url = '';
$upload_count = 0;
// Load and initialise WP_Filesystem.
if ( ! function_exists( 'WP_Filesystem' ) ) {
@ -123,7 +144,13 @@ class Media_Uploader {
foreach ( $files as $key => $local_path ) {
// Skip if file doesn't exist.
if ( ! $wp_filesystem->exists( $local_path ) ) {
$this->logger->warning( 'Media file not found', array( 'path' => $local_path ) );
$this->logger->warning(
'File does not exist, skipping upload',
array(
'key' => $key,
'path' => $local_path,
)
);
continue;
}
@ -132,14 +159,58 @@ class Media_Uploader {
? $meta['file']
: dirname( $meta['file'] ) . '/' . $key;
// Check if file already exists in S3.
try {
$exists = $client->headObject(
array(
'Bucket' => $this->config->get_bucket(),
'Key' => $object_key,
)
);
// 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 ) {
// Skip this file if it can't be read.
$this->logger->error( 'Failed to read media file', array( 'path' => $local_path ) );
$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(
@ -154,13 +225,18 @@ class Media_Uploader {
// Log successful upload.
$this->logger->s3_operation( 'putObject', true, basename( $object_key ) );
// Capture the ObjectURL for the original image.
if ( 'original' === $key && ! empty( $result['ObjectURL'] ) ) {
// Capture the base URL for constructing CDN URLs.
if ( 'original' === $key ) {
// Build CDN URL if domain configured, otherwise use S3 URL.
if ( $this->config->has_domain() ) {
$s3_base_url = trailingslashit( $this->config->get_domain() ) . dirname( $meta['file'] );
} elseif ( ! empty( $result['ObjectURL'] ) ) {
$object_url = $result['ObjectURL'];
$s3_base_url = dirname( $result['ObjectURL'] );
}
}
// Delete local file via WP_Filesystem.
$wp_filesystem->delete( $local_path );
$upload_count++;
} catch ( \Aws\Exception\AwsException $e ) {
// Log failed upload.
@ -170,26 +246,48 @@ class Media_Uploader {
}
}
// Preserve relative path in database.
update_post_meta( $attachment_id, '_wp_attached_file', $meta['file'] );
if ( $object_url ) {
// Update the GUID in wp_posts to the S3 URL.
wp_update_post(
// Log upload summary.
$this->logger->info(
sprintf( 'Upload complete: %d of %d files uploaded to S3', $upload_count, count( $files ) ),
array(
'ID' => $attachment_id,
'guid' => $object_url,
'attachment_id' => $attachment_id,
'uploaded' => $upload_count,
'expected' => count( $files ),
)
);
// Override front-end URL to use the S3 ObjectURL.
add_filter(
'wp_get_attachment_url',
function ( string $url, int $id ) use ( $object_url, $attachment_id ): string {
return ( $id === $attachment_id ) ? $object_url : $url;
},
10,
2
// Update metadata and schedule deletion if files were uploaded.
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() );
// 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 GUID to use CDN URL if available, otherwise S3 URL.
if ( $s3_base_url ) {
$file_name = basename( $meta['file'] );
if ( $this->config->has_domain() ) {
// Use CDN domain.
$public_url = trailingslashit( $this->config->get_domain() ) . $meta['file'];
} elseif ( $object_url ) {
// Use S3 ObjectURL.
$public_url = $object_url;
} else {
// Fallback: construct from base URL.
$public_url = $s3_base_url . '/' . $file_name;
}
// Update the GUID to the public URL.
wp_update_post(
array(
'ID' => $attachment_id,
'guid' => $public_url,
)
);
}
@ -210,4 +308,103 @@ class Media_Uploader {
$this->upload_attachment_to_idrivee2( $meta, $post_id );
}
}
/**
* Schedule files for deletion.
*
* 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
*
* @param array<string> $files Array of file paths to delete.
* @return void
*/
private function schedule_files_for_deletion( array $files ): void {
$queue = get_option( 'idrivee2_deletion_queue', array() );
foreach ( $files as $file_path ) {
$queue[] = array(
'path' => $file_path,
'timestamp' => time(),
);
}
update_option( 'idrivee2_deletion_queue', $queue, false );
}
/**
* Clean up local files that were uploaded to S3.
*
* This method runs via WP-Cron every 5 minutes. It deletes files that:
* - Were uploaded to S3 successfully
* - Have been in the queue for at least 3 minutes
*
* The 3-minute delay ensures WordPress has time to display thumbnails
* in the admin before files are removed.
*
* @since 1.0.1
*
* @return void
*/
public function cleanup_local_files(): void {
$queue = get_option( 'idrivee2_deletion_queue', array() );
if ( empty( $queue ) ) {
return;
}
// Load and initialise WP_Filesystem.
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
global $wp_filesystem;
$current_time = time();
$new_queue = array();
$deleted = 0;
foreach ( $queue as $item ) {
$file_path = $item['path'];
$timestamp = $item['timestamp'];
// Only delete files older than 3 minutes.
if ( ( $current_time - $timestamp ) < 180 ) {
$new_queue[] = $item;
continue;
}
// Delete the file if it exists.
if ( $wp_filesystem->exists( $file_path ) ) {
$result = $wp_filesystem->delete( $file_path );
if ( $result ) {
$deleted++;
$this->logger->info(
'Local file deleted after S3 upload',
array( 'path' => basename( $file_path ) )
);
} else {
// Keep in queue to retry later.
$new_queue[] = $item;
$this->logger->warning(
'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 );
// Log cleanup summary if any files were deleted.
if ( $deleted > 0 ) {
$this->logger->info(
sprintf( 'Cleanup completed: %d files deleted', $deleted ),
array( 'remaining' => count( $new_queue ) )
);
}
}
}

View file

@ -138,12 +138,31 @@ class Plugin {
// Load text domain for translations.
add_action( 'plugins_loaded', array( $this, 'load_textdomain' ), 20 );
// Add custom cron interval.
add_filter( 'cron_schedules', array( $this, 'add_cron_intervals' ) );
// Register component hooks.
$this->admin_page->register();
$this->media_uploader->register();
$this->url_rewriter->register();
}
/**
* Add custom cron intervals.
*
* @since 1.0.1
*
* @param array<string, array<string, mixed>> $schedules Existing schedules.
* @return array<string, array<string, mixed>> Modified schedules.
*/
public function add_cron_intervals( array $schedules ): array {
$schedules['every_five_minutes'] = array(
'interval' => 300,
'display' => __( 'Every 5 Minutes', 'idrivee2-media-upload' ),
);
return $schedules;
}
/**
* Load the plugin text domain for translations.
*

View file

@ -104,7 +104,7 @@ msgid "iDrivee2 Media Upload"
msgstr ""
#. Plugin URI of the plugin/theme
msgid "https://github.com/javiercasares/idrivee2-media-upload"
msgid "https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload"
msgstr ""
#. Description of the plugin/theme

View file

@ -20,35 +20,47 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
/**
* Clean up plugin data.
*
* Note: This plugin does not create any custom database tables or options.
* All data is stored in standard WordPress post meta and attachment metadata.
* This removes all custom post meta, options, and scheduled cron events
* created by the plugin.
*
* If you want to delete all attachment metadata created by this plugin,
* uncomment the code below. WARNING: This cannot be undone.
* WARNING: This action cannot be undone.
*/
// phpcs:disable Squiz.PHP.CommentedOutCode.Found
/*
// Delete all _wp_attached_file meta that was preserved by this plugin.
// This is optional as WordPress manages this meta normally.
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Delete all _idrivee2_last_upload post meta.
$wpdb->query(
"DELETE FROM {$wpdb->postmeta}
WHERE meta_key = '_wp_attached_file'
AND post_id IN (
SELECT ID FROM {$wpdb->posts}
WHERE post_type = 'attachment'
)"
WHERE meta_key = '_idrivee2_last_upload'"
);
// Delete all _idrivee2_s3_base_url post meta.
$wpdb->query(
"DELETE FROM {$wpdb->postmeta}
WHERE meta_key = '_idrivee2_s3_base_url'"
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
*/
// Delete the deletion queue option.
delete_option( 'idrivee2_deletion_queue' );
// Unschedule the cleanup cron event.
$timestamp = wp_next_scheduled( 'idrivee2_cleanup_local_files' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'idrivee2_cleanup_local_files' );
}
// Clear all hooks for this action to prevent any remaining schedules.
wp_clear_scheduled_hook( 'idrivee2_cleanup_local_files' );
/**
* Note: Files uploaded to S3 are NOT deleted by this uninstall script.
* If you want to delete files from S3, you must do so manually using
* your S3 management console or AWS CLI.
*
* Local files in wp-content/uploads/ are also NOT deleted, as they are
* part of WordPress's standard media library structure.
*/

View file

@ -3,7 +3,7 @@
'name' => 'javiercasares/idrivee2-media-upload',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '758c667aed0ce75fdafa62357cf9c5d8b6d1beae',
'reference' => 'ce690d2ae28f4364878953c73e3aa3c88642d599',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -58,7 +58,7 @@
'javiercasares/idrivee2-media-upload' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '758c667aed0ce75fdafa62357cf9c5d8b6d1beae',
'reference' => 'ce690d2ae28f4364878953c73e3aa3c88642d599',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),