feat: add S3-compatible storage provider (MinIO, Ceph, R2, etc.)

Adds a new 'S3-Compatible Storage' provider that works with any
S3-API-compatible object storage service, including MinIO, Ceph,
Cloudflare R2, Backblaze B2, and others.

Changes:
- New provider class: classes/providers/storage/s3-compatible-provider.php
  - Provider key: s3compatible
  - Reads user-configured endpoint URL from settings
  - Uses path-style URL access (required by most S3-compatible services)
  - Supports credentials via AS3CF_S3COMPAT_ACCESS_KEY_ID /
    AS3CF_S3COMPAT_SECRET_ACCESS_KEY wp-config.php constants
  - Disables AWS-specific features (Block Public Access, Object Ownership)
- New provider SVG icons (s3compatible.svg, -link.svg, -round.svg)
- Registered provider in main plugin class with endpoint setting support
- Updated StorageProviderSubPage to show endpoint URL input for S3-compatible
- Built pro settings bundle with rollup (Svelte 4.2.19)
- Added package.json and updated rollup.config.mjs for pro-only builds
This commit is contained in:
2026-03-03 12:30:18 +01:00
commit 3248cbb029
2086 changed files with 359427 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item;
use Exception;
class Add_Metadata_Process extends Uploader_Process {
/**
* @var string
*/
protected $action = 'add_metadata';
/**
* Increased batch limit as there is no network or file handling.
*
* @var int
*/
protected $limit = 2000;
/**
* Increased chunk size as there is no network or file handling.
*
* @var int
*/
protected $chunk = 100;
/**
* Create metadata as if item has been offloaded to provider, but don't actually do an offload.
*
* @param string $source_type
* @param int $source_id
* @param int $blog_id
*
* @return bool
* @throws Exception
*/
protected function handle_item( $source_type, $source_id, $blog_id ) {
// Skip item if already on provider.
if ( Media_Library_Item::get_by_source_id( $source_id ) ) {
return false;
}
$options = array(
'originator' => Item::ORIGINATORS['metadata-tool'],
'is_verified' => false,
'use_object_versioning' => false,
);
$as3cf_item = Media_Library_Item::create_from_source_id( $source_id, $options );
// Build error message
if ( is_wp_error( $as3cf_item ) ) {
foreach ( $as3cf_item->get_error_messages() as $error_message ) {
$error_msg = sprintf( __( 'Error adding metadata - %s', 'amazon-s3-and-cloudfront' ), $error_message );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
}
return false;
} else {
$as3cf_item->save();
}
return true;
}
/**
* Get reached license limit notice message.
*
* @return string
*/
protected function get_reached_license_limit_message() {
$account_link = sprintf( '<a href="%s" target="_blank">%s</a>', $this->as3cf->get_my_account_url(), __( 'My Account', 'amazon-s3-and-cloudfront' ) );
$notice_msg = __( "You've reached your license limit so we've had to stop adding metadata. To add metadata to the rest of your Media Library, please upgrade your license from %s and simply start the add metadata tool again. It will start from where it stopped.", 'amazon-s3-and-cloudfront' );
return sprintf( $notice_msg, $account_link );
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished adding metadata to Media Library.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has completed.
*/
protected function completed() {
$this->as3cf->update_media_library_total();
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item;
use Exception;
use WP_Error;
abstract class Analyze_And_Repair_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'analyze_and_repair';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
$processed = $source_ids;
foreach ( $source_ids as $source_id ) {
if ( $this->as3cf->is_attachment_served_by_provider( $source_id, true, true ) ) {
$this->handle_item( $source_type, $source_id, $blog_id );
}
}
// Whether handled or not, we processed every item.
return $processed;
}
/**
* Analyze and repair each item's offload metadata and log any errors.
*
* @param string $source_type
* @param int $source_id
* @param int $blog_id
*
* @return bool
* @throws Exception
*/
protected function handle_item( $source_type, $source_id, $blog_id ) {
$as3cf_item = Media_Library_Item::get_by_source_id( $source_id );
if ( empty( $as3cf_item ) ) {
return false;
}
$result = $this->analyze_and_repair( $as3cf_item );
// Build generic error message.
if ( is_wp_error( $result ) ) {
foreach ( $result->get_error_messages() as $error_message ) {
$error_msg = sprintf( __( 'Error - %s', 'amazon-s3-and-cloudfront' ), $error_message );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
}
return false;
}
return true;
}
/**
* Performs required analysis and repairs for given offloaded item.
*
* @param Media_Library_Item $as3cf_item
*
* @return bool|WP_Error Returns false if no action required, true if repaired, or WP_Error if could not be processed or repaired.
*/
abstract protected function analyze_and_repair( Media_Library_Item $as3cf_item );
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes\Analyze_And_Repair;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item;
use DeliciousBrains\WP_Offload_Media\Pro\Background_Processes\Analyze_And_Repair_Process;
use WP_Error;
class Reverse_Add_Metadata_Process extends Analyze_And_Repair_Process {
/**
* @var string
*/
protected $action = 'reverse_add_metadata';
/**
* Get blog items to process.
*
* @param string $source_type Item source type
* @param int $last_source_id The ID of the last item previously processed
* @param int $limit Maximum number of item IDs to return
* @param bool $count Just return the count, negates $limit, default false
*
* @return array|int
*/
protected function get_blog_items( $source_type, $last_source_id, $limit, $count = false ) {
return Media_Library_Item::get_source_ids( $last_source_id, $limit, $count, Item::ORIGINATORS['metadata-tool'] );
}
/**
* Performs required analysis and repairs for given offloaded item.
*
* @param Media_Library_Item $as3cf_item
*
* @return bool|WP_Error Returns false if no action required, true if repaired, or WP_Error if could not be processed or repaired.
*/
protected function analyze_and_repair( Media_Library_Item $as3cf_item ) {
return $as3cf_item->delete();
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished removing items previously created with the Add Metadata tool.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has completed.
*/
protected function completed() {
delete_site_option( $this->prefix . '_add_metadata_last_started' );
$this->as3cf->update_media_library_total();
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes\Analyze_And_Repair;
use AS3CF_Utils;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item;
use DeliciousBrains\WP_Offload_Media\Pro\Background_Processes\Analyze_And_Repair_Process;
use Exception;
use WP_Error;
class Verify_Add_Metadata_Process extends Analyze_And_Repair_Process {
/**
* @var string
*/
protected $action = 'verify_add_metadata';
/**
* Get blog items to process.
*
* @param string $source_type Item source type
* @param int $last_source_id The ID of the last item previously processed
* @param int $limit Maximum number of item IDs to return
* @param bool $count Just return the count, negates $limit, default false
*
* @return array|int
*/
protected function get_blog_items( $source_type, $last_source_id, $limit, $count = false ) {
return Media_Library_Item::get_source_ids( $last_source_id, $limit, $count, Item::ORIGINATORS['metadata-tool'], false );
}
/**
* Performs required analysis and repairs for given offloaded item.
*
* @param Media_Library_Item $as3cf_item
*
* @return bool|WP_Error Returns false if no action required, true if repaired, or WP_Error if could not be processed or repaired.
*/
protected function analyze_and_repair( Media_Library_Item $as3cf_item ) {
$bucket = $as3cf_item->bucket();
if ( empty( $bucket ) ) {
return new WP_Error( 'exception', 'Could not get bucket for item.' );
}
$region = $as3cf_item->region();
if ( is_wp_error( $region ) ) {
return new WP_Error( 'exception', 'Could not get region for bucket: ' . $region->get_error_message() );
} elseif ( ! is_string( $region ) ) {
return new WP_Error( 'exception', "Could not get region for item's bucket." );
}
try {
$provider_client = $this->as3cf->get_provider_client( $region );
} catch ( Exception $e ) {
return new WP_Error( 'exception', 'Could not get provider client: ' . $e->getMessage() );
}
$paths = AS3CF_Utils::get_attachment_file_paths( $as3cf_item->source_id(), false );
if ( empty( $paths ) ) {
return new WP_Error( 'exception', 'Could not get paths for Media Library item with ID: ' . $as3cf_item->source_id() );
}
$fullsize_paths = AS3CF_Utils::fullsize_paths( $paths );
if ( empty( $fullsize_paths ) ) {
return new WP_Error( 'exception', 'Could not get full size paths for Media Library item with ID: ' . $as3cf_item->source_id() );
}
$fullsize_exists = false;
$fullsize_missing = false;
foreach ( $fullsize_paths as $path ) {
$key = $as3cf_item->key( wp_basename( $path ) );
if ( $provider_client->does_object_exist( $bucket, $key ) ) {
$fullsize_exists = true;
} else {
$fullsize_missing = true;
}
}
// A full sized file has not been found, remove metadata.
if ( ! $fullsize_exists ) {
$as3cf_item->delete();
return false;
}
// At least one full sized file has been found, set as verified.
$as3cf_item->set_is_verified( true );
$as3cf_item->save();
// Need to log that sizes need regeneration?
// NOTE: As we currently do not have a means of setting individual thumbnails sizes as verified, we can shortcut out here.
// NOTE: In the future we should record whether each size is verified and/or remove details record.
if ( $fullsize_missing ) {
return new WP_Error( 'exception', 'Thumbnails need regenerating for Media Library item with ID: ' . $as3cf_item->source_id() );
}
// If item has sizes, check them too.
$size_paths = array_diff( $paths, $fullsize_paths );
if ( empty( $size_paths ) ) {
return true;
}
foreach ( $size_paths as $path ) {
$key = $as3cf_item->key( wp_basename( $path ) );
if ( ! $provider_client->does_object_exist( $bucket, $key ) ) {
return new WP_Error( 'exception', 'Thumbnails need regenerating for Media Library item with ID: ' . $as3cf_item->source_id() );
}
}
return true;
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished checking or removing items previously created with the Add Metadata tool.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has completed.
*/
protected function completed() {
$this->as3cf->update_media_library_total();
}
}

View File

@@ -0,0 +1,387 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use AS3CF_Background_Process;
use AS3CF_Error;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Pro\Tool;
abstract class Background_Tool_Process extends AS3CF_Background_Process {
/**
* @var Tool
*/
protected $tool;
/**
* Default batch limit.
*
* @var int
*/
protected $limit = 100;
/**
* Default chunk size.
*
* @var int
*/
protected $chunk = 10;
/**
* @var array
*/
protected $errors = array();
/**
* @var int
*/
protected $reported_errors_limit = 100;
/**
* Initiate new background tool process.
*
* @param object $as3cf Instance of calling class
* @param Tool $tool
*/
public function __construct( $as3cf, $tool ) {
parent::__construct( $as3cf );
$this->tool = $tool;
}
/**
* Task
*
* Override this method to perform any actions required on each
* queue item. Return the modified item for further processing
* in the next pass through. Or, return false to remove the
* item from the queue.
*
* @param mixed $item Queue item to iterate over
*
* @return mixed
*/
protected function task( $item ) {
if ( ! $item['blogs_processed'] ) {
// Calculate how many items each blog has,
// and return immediately to allow monitoring
// processes see initial state.
$item = $this->calculate_blog_items( $item );
if ( $this->all_blog_items_processed( $item ) ) {
// Nothing to do, remove from queue.
return false;
} else {
return $item;
}
}
if ( $this->all_blog_items_processed( $item ) ) {
// Batch complete, remove from queue
return false;
}
return $this->process_blogs( $item );
}
/**
* Calculate the number of items across all blogs.
*
* @param array $item
*
* @return array
*/
protected function calculate_blog_items( $item ) {
foreach ( $item['blogs'] as $blog_id => $blog ) {
if ( $this->time_exceeded() || $this->memory_exceeded() ) {
// Batch limits reached
return $item;
}
if ( ! is_null( $blog['total_items'] ) ) {
// Blog already processed, move on
continue;
}
$this->as3cf->switch_to_blog( $blog_id );
foreach ( $blog['processed'] as $source_type => $processed ) {
if ( isset( $blog['last_source_id'][ $source_type ] ) && is_numeric( $blog['last_source_id'][ $source_type ] ) ) {
$last_source_id = $blog['last_source_id'][ $source_type ];
} else {
$last_source_id = null;
}
$total = $this->get_blog_items( $source_type, $last_source_id, null, true );
if ( ! empty( $total ) ) {
$item['blogs'][ $blog_id ]['total_items'] += $total;
$item['blogs'][ $blog_id ]['last_source_id'][ $source_type ] = $this->get_blog_last_source_id( $source_type ) + 1;
$item['total_items'] += $total;
} else {
$item['blogs'][ $blog_id ]['processed'][ $source_type ] = true;
$item['blogs'][ $blog_id ]['total_items'] = 0;
}
}
$this->as3cf->restore_current_blog();
}
$item['blogs_processed'] = true;
return $item;
}
/**
* Loop over each blog and process items.
*
* @param array $item
*
* @return array
*/
protected function process_blogs( $item ) {
$this->errors = $this->tool->get_errors();
foreach ( $item['blogs'] as $blog_id => $blog ) {
if ( $this->time_exceeded() || $this->memory_exceeded() ) {
// Batch limits reached
break;
}
if ( $this->all_source_types_processed( $blog ) ) {
// Blog processed, move onto the next
continue;
}
$this->as3cf->switch_to_blog( $blog_id );
$limit = apply_filters( "as3cf_tool_{$this->action}_batch_size", $this->limit );
foreach ( $blog['last_source_id'] as $source_type => $last_source_id ) {
$items = $this->get_blog_items( $source_type, $last_source_id, $limit );
$item = $this->process_blog_items( $item, $blog_id, $source_type, $items );
}
// If we've just finished processing a subsite, force update its totals.
if ( is_multisite() && $this->all_source_types_processed( $item['blogs'][ $blog_id ] ) ) {
$this->as3cf->media_counts( true, true, $blog_id );
}
$this->as3cf->restore_current_blog();
}
if ( count( $this->errors ) ) {
$this->tool->update_errors( $this->errors );
$this->tool->update_error_notice( $this->errors );
$this->tool->undismiss_error_notice();
}
return $item;
}
/**
* Process blog items.
*
* @param array $item
* @param int $blog_id
* @param string $source_type
* @param array $items
*
* @return array
*/
protected function process_blog_items( $item, $blog_id, $source_type, $items ) {
$chunks = array_chunk( $items, $this->chunk );
foreach ( $chunks as $chunk ) {
$processed = $this->process_items_chunk( $source_type, $chunk, $blog_id );
if ( ! empty( $processed ) ) {
$item['processed_items'] += count( $processed );
$item['blogs'][ $blog_id ]['last_source_id'][ $source_type ] = end( $processed );
}
if ( $this->time_exceeded() || $this->memory_exceeded() || count( $chunk ) > count( $processed ) ) {
break;
}
}
if ( empty( $items ) ) {
$item['blogs'][ $blog_id ]['processed'][ $source_type ] = true;
}
return $item;
}
/**
* Get blog items to process.
*
* @param string $source_type Item source type
* @param int $last_source_id The ID of the last item previously processed
* @param int $limit Maximum number of item IDs to return
* @param bool $count Just return the count, negates $limit, default false
*
* @return array|int
*/
protected function get_blog_items( $source_type, $last_source_id, $limit, $count = false ) {
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
return $class::get_source_ids( $last_source_id, $limit, $count );
}
/**
* Get blog last item ID.
*
* @param string $source_type Item source type
*
* @return int
*/
protected function get_blog_last_source_id( $source_type ) {
$items = $this->get_blog_items( $source_type, null, 1 );
return empty( $items ) ? 0 : reset( $items );
}
/**
* Have all blog items been processed?
*
* @param array $item
*
* @return bool
*/
protected function all_blog_items_processed( $item ) {
foreach ( $item['blogs'] as $blog ) {
if ( ! $this->all_source_types_processed( $blog ) ) {
return false;
}
}
return true;
}
/**
* Have all item types in blog been processed?
*
* @param array $blog
*
* @return bool
*/
protected function all_source_types_processed( $blog ) {
foreach ( $blog['processed'] as $processed ) {
if ( ! $processed ) {
return false;
}
}
return true;
}
/**
* Record error.
*
* @param int $blog_id
* @param string $source_type
* @param int $source_id
* @param string $message
*/
protected function record_error( $blog_id, $source_type, $source_id, $message ) {
AS3CF_Error::log( $message );
// Existing entry for item to append message to?
foreach ( $this->errors as $error ) {
if ( $error->blog_id === $blog_id && $error->source_type === $source_type && $error->source_id === $source_id ) {
$error->messages[] = $message;
return;
}
}
// Restrict to $reported_errors_limit entries in the UI.
if ( $this->count_errors() >= $this->reported_errors_limit ) {
return;
}
$this->errors[] = (object) array(
'blog_id' => $blog_id,
'source_type' => $source_type,
'source_id' => $source_id,
'messages' => array( $message ),
);
}
/**
* How many items have had errors recorded by this process?
*
* @return int
*/
protected function count_errors() {
return is_array( $this->errors ) ? count( $this->errors ) : 0;
}
/**
* Complete
*
* Override if applicable, but ensure that the below actions are
* performed, or, call parent::complete().
*/
protected function complete() {
parent::complete();
$notice_id = $this->tool->get_tool_key() . '_completed';
$this->as3cf->notices->undismiss_notice_for_all( $notice_id );
$this->as3cf->notices->remove_notice_by_id( $notice_id );
if ( $this->tool->get_errors() ) {
$message = $this->get_complete_with_errors_message();
$type = 'notice-warning';
} else {
$message = $this->get_complete_message();
$type = 'updated';
}
$args = array(
'custom_id' => $notice_id,
'type' => $type,
'flash' => false,
'only_show_to_user' => false,
);
$this->as3cf->notices->add_notice( $message, $args );
}
/**
* Adds a note about errors to completion message.
*
* @return string
*/
protected function get_complete_with_errors_message() {
$msg = $this->get_complete_message() . ' ';
$msg .= sprintf(
'<a href="%1$s">',
$this->as3cf->get_plugin_page_url( array( 'hash' => '/tools/' ) )
);
$msg .= __( 'Some errors were recorded.', 'amazon-s3-and-cloudfront' );
$msg .= '</a>';
return $msg;
}
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*/
abstract protected function process_items_chunk( $source_type, $source_ids, $blog_id );
/**
* Get complete notice message.
*
* @return string
*/
abstract protected function get_complete_message();
}

View File

@@ -0,0 +1,235 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use AS3CF_Error;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Providers\Storage\Storage_Provider;
use Exception;
class Copy_Buckets_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'copy_buckets';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
$bucket = $this->as3cf->get_setting( 'bucket' );
$region = $this->as3cf->get_setting( 'region' );
$class = $this->as3cf->get_source_type_class( $source_type );
$items_to_copy = array();
foreach ( $source_ids as $source_id ) {
/** @var Item $class */
$as3cf_item = $class::get_by_source_id( $source_id );
if ( false === $as3cf_item || is_wp_error( $as3cf_item ) ) {
continue;
}
if ( $as3cf_item->bucket() === $bucket ) {
continue;
}
$items_to_copy[] = $source_id;
}
$this->copy_items( $items_to_copy, $blog_id, $bucket, $region, $source_type );
// Whether copied or not, we processed every item.
return $source_ids;
}
/**
* Copy items to new bucket.
*
* @param array $items
* @param int $blog_id
* @param string $bucket
* @param string $region
* @param string $source_type
*
* @throws Exception
*/
protected function copy_items( $items, $blog_id, $bucket, $region, $source_type ) {
if ( empty( $items ) ) {
return;
}
$keys = $this->as3cf->get_provider_keys( $items, $source_type );
if ( empty( $keys ) ) {
return;
}
$items_to_copy = array();
$skipped = array();
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
foreach ( $keys as $source_id => $source_keys ) {
/** @var Item $as3cf_item */
$as3cf_item = $class::get_by_source_id( $source_id );
if ( ! $as3cf_item->served_by_provider( true ) ) {
$name = $this->as3cf->get_source_type_name( $source_type );
$skipped[] = array(
'Key' => $source_keys[0],
'Message' => sprintf( __( '%s item with ID %s is offloaded to a different provider than currently configured', 'amazon-s3-and-cloudfront' ), $name, $source_id ),
);
continue;
}
foreach ( $source_keys as $key ) {
$args = array(
'Bucket' => $bucket,
'Key' => $key,
'CopySource' => urlencode( "{$as3cf_item->bucket()}/{$key}" ),
);
$size = $as3cf_item->get_object_key_from_filename( $key );
$acl = $as3cf_item->get_acl_for_object_key( $size, $bucket );
// Only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
if ( ! empty( $acl ) ) {
$args['ACL'] = $acl;
}
$args = Storage_Provider::filter_object_meta( $args, $as3cf_item, $size, true );
// Protect against filter use and only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
if ( isset( $args['ACL'] ) && empty( $acl ) ) {
unset( $args['ACL'] );
}
$items_to_copy[] = $args;
}
}
$failures = array();
if ( ! empty( $items_to_copy ) ) {
$client = $this->as3cf->get_provider_client( $region, true );
try {
$failures = $client->copy_objects( $items_to_copy );
} catch ( Exception $e ) {
AS3CF_Error::log( $e->getMessage() );
return;
}
}
$failures = $failures + $skipped;
if ( ! empty( $failures ) ) {
$keys = $this->handle_failed_keys( $keys, $failures, $blog_id, $source_type );
}
$this->update_item_provider_info( $keys, $bucket, $region, $source_type );
}
/**
* Handle failed keys.
*
* @param array $keys
* @param array $failures
* @param int $blog_id
* @param string $source_type
*
* @return array
*/
protected function handle_failed_keys( $keys, $failures, $blog_id, $source_type ) {
foreach ( $failures as $failure ) {
foreach ( $keys as $source_id => $source_keys ) {
if ( false !== array_search( $failure['Key'], $source_keys ) ) {
$error_msg = sprintf( __( 'Error copying %s between buckets: %s', 'amazon-s3-and-cloudfront' ), $failure['Key'], $failure['Message'] );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
unset( $keys[ $source_id ] );
break;
}
}
}
return $keys;
}
/**
* Update item provider info.
*
* @param array $keys
* @param string $bucket
* @param string $region
* @param string $source_type
*/
protected function update_item_provider_info( $keys, $bucket, $region, $source_type ) {
if ( empty( $keys ) ) {
return;
}
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
foreach ( $keys as $source_id => $source_keys ) {
$as3cf_item = $class::get_by_source_id( $source_id );
$as3cf_item->set_region( $region );
$as3cf_item->set_bucket( $bucket );
$as3cf_item->save();
}
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished copying media files to new bucket.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Pro\Items\Remove_Provider_Handler;
use Exception;
class Download_And_Remover_Process extends Downloader_Process {
/**
* @var string
*/
protected $action = 'download_and_remover';
/**
* Download and remove the item from bucket.
*
* @param string $source_type
* @param int $source_id
* @param int $blog_id
*
* @return bool
* @throws Exception
*/
protected function handle_item( $source_type, $source_id, $blog_id ) {
if ( parent::handle_item( $source_type, $source_id, $blog_id ) ) {
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$as3cf_item = $class::get_by_source_id( $source_id );
/** @var Remove_Provider_Handler $remove_handler */
$remove_handler = $this->as3cf->get_item_handler( Remove_Provider_Handler::get_item_handler_key_name() );
// As we've already confirmed that local files exist,
// and not had to record any errors for display,
// we can skip confirming that files exist on local,
// or that the remove from provider succeeded.
$result = $remove_handler->handle( $as3cf_item, array( 'verify_exists_on_local' => false ) );
if ( is_wp_error( $result ) ) {
foreach ( $result->get_error_messages() as $error_message ) {
$error_msg = sprintf( __( 'Error removing from bucket - %s', 'amazon-s3-and-cloudfront' ), $error_message );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
}
return false;
}
$as3cf_item->delete();
return true;
}
return false;
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
$this->as3cf->update_media_library_total();
}
/**
* Called when background process has been paused.
*/
protected function paused() {
$this->as3cf->update_media_library_total();
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
$this->as3cf->update_media_library_total();
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished removing media files from bucket.', 'amazon-s3-and-cloudfront' );
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use DeliciousBrains\WP_Offload_Media\Items\Download_Handler;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use Exception;
class Downloader_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'downloader';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
$processed = $source_ids;
foreach ( $source_ids as $source_id ) {
$this->handle_item( $source_type, $source_id, $blog_id );
}
// Whether downloaded to local or not, we processed every item.
return $processed;
}
/**
* Download the item from bucket.
*
* @param string $source_type
* @param int $source_id
* @param int $blog_id
*
* @return bool
* @throws Exception
*/
protected function handle_item( $source_type, $source_id, $blog_id ) {
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$as3cf_item = $class::get_by_source_id( $source_id );
if ( ! $as3cf_item ) {
return false;
}
/** @var Download_Handler $download_handler */
$download_handler = $this->as3cf->get_item_handler( Download_Handler::get_item_handler_key_name() );
$result = $download_handler->handle( $as3cf_item );
if ( is_wp_error( $result ) ) {
foreach ( $result->get_error_messages() as $error_message ) {
$error_msg = sprintf( __( 'Error downloading to server - %s', 'amazon-s3-and-cloudfront' ), $error_message );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
}
return false;
}
return true;
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished downloading media files to local server.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use AS3CF_Error;
use DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item;
use Exception;
class Elementor_Analyze_And_Repair_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'elementor_analyze_and_repair';
/**
* Process chunk of posts
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
foreach ( $source_ids as $source_id ) {
$this->handle_item( $source_type, $source_id, $blog_id );
}
// We processed every item.
return $source_ids;
}
/**
* Process individual posts/pages with Elementor data and update any remote
* URLs to the corresponding local version.
*
* @param string $source_type
* @param int $post_id
* @param int $blog_id
*
* @return bool
* @throws Exception
*/
protected function handle_item( $source_type, $post_id, $blog_id ) {
$original_json = get_post_meta( $post_id, '_elementor_data', true );
$skip_values = array( 'true', 'false', 'null' );
if ( empty( $original_json ) || ( is_string( $original_json ) && in_array( trim( $original_json ), $skip_values, true ) ) ) {
return true;
}
// We should always get data in JSON format, that's what Elementor saves it as. In tests
// we've seen it stored as a serialized array in a few cases. Elementor can read both.
// For good measure we test and convert if needed.
if ( is_array( $original_json ) ) {
$original_json = wp_json_encode( $original_json );
if ( false === $original_json ) {
$error_msg = sprintf( __( 'Existing elementor data for post - %d contains a serialized array that could not be converted to JSON - skipping', 'amazon-s3-and-cloudfront' ), $post_id );
AS3CF_Error::log( $error_msg );
return false;
}
}
// Verify that the original post meta contains valid JSON
if ( is_string( $original_json ) ) {
$decoded_original = json_decode( $original_json, true );
if ( is_null( $decoded_original ) ) {
$error_msg = sprintf( __( 'Existing elementor data for post - %d contains invalid JSON - skipping', 'amazon-s3-and-cloudfront' ), $post_id );
AS3CF_Error::log( $error_msg );
return false;
}
}
$modified_json = $original_json;
$modified_json = str_replace( '\/', '/', $modified_json );
$modified_json = $this->as3cf->filter_provider->filter_post( $modified_json );
// Verify that we still have valid JSON
$decoded = json_decode( $modified_json, true );
if ( is_null( $decoded ) ) {
$error_msg = sprintf( __( 'Error replacing URLs in Elementor data for post - %d results in invalid JSON', 'amazon-s3-and-cloudfront' ), $post_id );
AS3CF_Error::log( $error_msg );
return false;
}
// Verify that the JSON can be re-encoded
$modified_json = wp_json_encode( $decoded );
if ( false === $modified_json ) {
$error_msg = sprintf( __( 'Error replacing URLs in Elementor data for post - %d JSON re-encoding failed', 'amazon-s3-and-cloudfront' ), $post_id );
AS3CF_Error::log( $error_msg );
return false;
}
if ( $modified_json !== $original_json ) {
update_post_meta( $post_id, '_elementor_data', wp_slash( $modified_json ) );
}
return true;
}
/**
* Return the count of Elementor posts
*
* @return int
*/
public function get_elementor_items_count() {
return $this->get_blog_items( Media_Library_Item::source_type(), null, null, true );
}
/**
* Find all items in the Posts table that are created with Elementor
*
* @param string $source_type Item source type
* @param int $last_post_id
* @param int $limit Maximum number of posts to return
* @param bool $count Just return the count, negates $limit, default false
*
* @return array|int
*/
protected function get_blog_items( $source_type, $last_post_id, $limit, $count = false ) {
global $wpdb;
$args = array();
if ( $count ) {
$sql = 'SELECT COUNT(DISTINCT p.id)';
} else {
$sql = 'SELECT DISTINCT p.id';
}
$sql .= " FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON p.id = m.post_id";
$sql .= " WHERE m.meta_key = '_elementor_data' AND p.post_status != 'inherit' ";
if ( is_numeric( $last_post_id ) ) {
$sql .= ' AND p.id < %d';
$args[] = $last_post_id;
}
if ( ! $count ) {
$sql .= ' ORDER BY p.id DESC LIMIT %d';
$args[] = $limit;
}
if ( count( $args ) > 0 ) {
$sql = $wpdb->prepare( $sql, $args );
}
if ( $count ) {
return $wpdb->get_var( $sql );
} else {
return array_map( 'intval', $wpdb->get_col( $sql ) );
}
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished the Elementor Analyze and Repair process.', 'amazon-s3-and-cloudfront' );
}
}

View File

@@ -0,0 +1,456 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use AS3CF_Error;
use AS3CF_Utils;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Providers\Storage\Storage_Provider;
use Exception;
class Move_Objects_Process extends Background_Tool_Process {
const MOVE_NO = 0;
const MOVE_YES = 1;
const MOVE_SAME = 2;
const MOVE_NOOP = 3;
/**
* @var string
*/
protected $action = 'move_objects';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$items_to_move = array();
if ( $this->as3cf->private_prefix_enabled() ) {
$new_private_prefix = $this->as3cf->get_setting( 'signed-urls-object-prefix', '' );
} else {
$new_private_prefix = '';
}
foreach ( $source_ids as $source_id ) {
$update = false;
$as3cf_item = $class::get_by_source_id( $source_id );
if ( $as3cf_item ) {
// Analyze current path to see if it needs changing.
$old_prefix = $as3cf_item->normalized_path_dir();
$new_prefix = $this->get_new_public_prefix( $as3cf_item, $old_prefix );
if ( $new_prefix !== $old_prefix ) {
$update = true;
}
// Analyze current private prefix to see if it needs changing.
$private_prefix = $as3cf_item->private_prefix();
switch ( $this->should_move_to_new_private_prefix( $as3cf_item, $private_prefix, $new_private_prefix ) ) {
case self::MOVE_NO:
case self::MOVE_SAME:
break;
case self::MOVE_NOOP:
// If nothing is to be moved to new private prefix, and public isn't being updated, just fix data.
if ( false === $update ) {
$as3cf_item->set_private_prefix( $new_private_prefix );
$as3cf_item->save();
continue 2;
}
$private_prefix = $new_private_prefix;
break;
case self::MOVE_YES:
$private_prefix = $new_private_prefix;
$update = true;
break;
}
if ( $update ) {
$items_to_move[ $source_id ] = array( 'prefix' => $new_prefix, 'private_prefix' => $private_prefix );
}
} else {
$name = $this->as3cf->get_source_type_name( $source_type );
AS3CF_Error::log( sprintf( 'Move Objects: Offload data for %s item with ID %d could not be found for analysis.', $name, $source_id ) );
}
}
$this->move_items( $items_to_move, $blog_id, $source_type );
// Whether moved or not, we processed every item.
return $source_ids;
}
/**
* Returns new public prefix if required, otherwise returns old prefix.
*
* phpcs:disable Generic.PHP.DiscourageGoto.Found
*
* @param Item $as3cf_item
* @param string $old_prefix
*
* @return string
*/
protected function get_new_public_prefix( Item $as3cf_item, $old_prefix ) {
$new_prefix = $as3cf_item->get_new_item_prefix();
// Length changed is simplest indicator.
if ( strlen( $old_prefix ) !== strlen( $new_prefix ) ) {
goto move_item;
}
$old_parts = explode( '/', trim( $old_prefix, '/' ) );
$new_parts = explode( '/', trim( $new_prefix, '/' ) );
// Number of path elements changed?
if ( count( $old_parts ) !== count( $new_parts ) ) {
goto move_item;
}
// If object versioning is on, don't compare last segment.
if ( $this->as3cf->get_setting( 'object-versioning', false ) && $as3cf_item->can_use_object_versioning() ) {
$old_parts = array_slice( $old_parts, 0, -1 );
$new_parts = array_slice( $new_parts, 0, -1 );
}
// Each element should now be the same.
// Simplest way to check here is walk one and check the other by index.
// No need to get all fancy!
foreach ( $old_parts as $key => $val ) {
if ( $new_parts[ $key ] !== $val ) {
goto move_item;
}
}
// If here, then prefix does not need to change, regardless of whether private prefix does.
// This could be important for mixed public/private thumbnails and external links.
// We already know that old and new prefix are the same except for object version,
// which is at least still using the same format (length check confirmed that).
$new_prefix = $old_prefix;
move_item:
return $new_prefix;
}
/**
* Should the given item be moved to the new private prefix?
*
* @param Item $as3cf_item
* @param string $old_private_prefix
* @param string $new_private_prefix
*
* @return int One of MOVE_NO, MOVE_YES, MOVE_SAME or MOVE_NOOP.
*/
protected function should_move_to_new_private_prefix( Item $as3cf_item, $old_private_prefix, $new_private_prefix ) {
// Analyze current private prefix to see if it needs changing.
if ( $old_private_prefix === $new_private_prefix ) {
// Private prefix not changed, nothing to do.
return self::MOVE_SAME;
} elseif ( ! $as3cf_item->is_private() && ! $as3cf_item->has_private_objects() ) {
// Not same, but nothing is to be moved to private prefix, maybe just fix data.
return self::MOVE_NOOP;
} else {
// Private prefix changed, move some private objects.
return self::MOVE_YES;
}
}
/**
* Move items to new path.
*
* @param array $items id => ['prefix' => 'new/path/prefix', 'private_prefix' => 'private']
* @param int $blog_id
* @param string $source_type
*
* @throws Exception
*
* Note: `private_prefix` will be prepended to `prefix` for any object that is private.
* `prefix` and `private_prefix` are "directory" paths and can have leading/trailing slashes, they'll be handled.
* Both `prefix` and `private_prefix` must be set per item id, but either/both may be empty.
*/
protected function move_items( $items, $blog_id, $source_type ) {
if ( empty( $items ) ) {
return;
}
$bucket = $this->as3cf->get_setting( 'bucket' );
$region = $this->as3cf->get_setting( 'region' );
if ( empty( $bucket ) ) {
return;
}
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$source_type_name = $this->as3cf->get_source_type_name( $source_type );
$keys = array();
$new_keys = array();
$items_to_move = array();
foreach ( array_keys( $items ) as $source_id ) {
/** @var Item $as3cf_item */
$as3cf_item = $class::get_by_source_id( $source_id );
$source_keys = array_unique( $as3cf_item->provider_keys() );
// If the item isn't served by this provider, skip it.
if ( ! $as3cf_item->served_by_provider( true ) ) {
$error_msg = sprintf( __( '% ID %s is offloaded to a different provider than currently configured', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
continue;
}
// If the prefix isn't set, skip it.
if ( ! isset( $items[ $source_id ]['prefix'] ) ) {
$error_msg = sprintf( __( 'Prefix not set for % ID %s (this should never happen, please report to support)', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
continue;
}
// If the private_prefix isn't set, skip it.
if ( ! isset( $items[ $source_id ]['private_prefix'] ) ) {
$error_msg = sprintf( __( 'Private prefix not set for %s ID %s (this should never happen, please report to support)', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
continue;
}
// If the item is offloaded to another bucket, skip it.
if ( $as3cf_item->bucket() !== $bucket ) {
$error_msg = sprintf( __( '%s ID %s is offloaded to a different bucket than currently configured', 'amazon-s3-and-cloudfront' ), $source_type_name, $source_id );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
continue;
}
$updated_item = clone $as3cf_item;
$updated_item->set_private_prefix( AS3CF_Utils::trailingslash_prefix( $items[ $source_id ]['private_prefix'] ) );
$updated_item->update_path_prefix( AS3CF_Utils::trailingslash_prefix( $items[ $source_id ]['prefix'] ) );
// TODO: Make sure we're not clobbering another item's path.
// Each key found in old paths will be moved to new path as appropriate for access.
foreach ( $source_keys as $object_key => $key ) {
$new_key = $updated_item->provider_key( $object_key );
// If the old and new key are the same, don't try and move it.
if ( $key === $new_key ) {
continue;
}
// We need to record the old and new key so that we can reconcile them later.
$keys[ $source_id ][] = $key;
$new_keys[ $source_id ][] = $new_key;
$args = array(
'Bucket' => $as3cf_item->bucket(),
'Key' => $new_key,
'CopySource' => urlencode( "{$as3cf_item->bucket()}/{$key}" ),
);
$acl = $as3cf_item->get_acl_for_object_key( $object_key );
// Only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
if ( ! empty( $acl ) ) {
$args['ACL'] = $acl;
}
$args = Storage_Provider::filter_object_meta( $args, $as3cf_item, $object_key );
// Protect against filter use and only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
if ( isset( $args['ACL'] ) && empty( $acl ) ) {
unset( $args['ACL'] );
}
$items_to_move[] = $args;
}
}
// All skipped, abort.
if ( empty( $items_to_move ) ) {
return;
}
/*
* As there is no such thing as "move" objects in supported providers, and we want to be able to roll-back
* an entire item's copies if any fail, we copy, check for failures, and then only delete old keys
* which have successfully copied. Any partially copied item have their successful copies deleted
* instead so as to not leave orphaned objects either with old or new key prefixes.
*/
$client = $this->as3cf->get_provider_client( $region, true );
try {
$failures = $client->copy_objects( $items_to_move );
} catch ( Exception $e ) {
AS3CF_Error::log( $e->getMessage() );
return;
}
if ( ! empty( $failures ) ) {
$keys_to_remove = $this->handle_failed_keys( $keys, $failures, $blog_id, $source_type, $new_keys );
} else {
$keys_to_remove = $keys;
}
// Prepare and batch delete all the redundant keys.
$objects_to_delete = array();
foreach ( $keys_to_remove as $source_id => $objects ) {
foreach ( $objects as $idx => $object ) {
// If key was not moved, don't delete it.
if ( in_array( $object, $keys[ $source_id ] ) && in_array( $object, $new_keys[ $source_id ] ) ) {
unset( $keys_to_remove[ $source_id ][ $idx ] );
continue;
}
$objects_to_delete[] = array( 'Key' => $object );
}
}
if ( ! empty( $objects_to_delete ) ) {
try {
$client->delete_objects( array(
'Bucket' => $bucket,
'Delete' => array(
'Objects' => $objects_to_delete,
),
) );
} catch ( Exception $e ) {
AS3CF_Error::log( $e->getMessage() );
}
}
$this->update_item_provider_info( $keys, $new_keys, $keys_to_remove, $items, $source_type );
}
/**
* Handle failed keys.
*
* @param array $keys id => ['path1', 'path2', ...]
* @param array $failures [] => ['Key', 'Message']
* @param int $blog_id
* @param string $source_type
* @param array $new_keys id => ['path1', 'path2', ...]
*
* @return array Keys that can be removed, old and new (roll-back)
*/
protected function handle_failed_keys( $keys, $failures, $blog_id, $source_type, $new_keys ) {
foreach ( $failures as $failure ) {
foreach ( $new_keys as $source_id => $source_keys ) {
$key_id = array_search( $failure['Key'], $source_keys );
if ( false !== $key_id ) {
$error_msg = sprintf(
__( 'Error moving %1$s to %2$s for item %3$d: %4$s', 'amazon-s3-and-cloudfront' ),
$keys[ $source_id ][ $key_id ],
$failure['Key'],
$source_id,
$failure['Message']
);
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
// Instead of deleting old keys for item, delete new ones (roll-back).
$keys[ $source_id ] = $new_keys[ $source_id ];
// Prevent further errors being shown for aborted item.
unset( $new_keys[ $source_id ] );
break;
}
}
}
return $keys;
}
/**
* Update item provider info.
*
* @param array $keys id => ['path1', 'path2', ...]
* @param array $new_keys id => ['path1', 'path2', ...]
* @param array $removed_keys id => ['path1', 'path2', ...]
* @param array $items id => ['prefix' => 'new/path/prefix', 'private_prefix' => 'private']
* @param string $source_type
*/
protected function update_item_provider_info( $keys, $new_keys, $removed_keys, $items, $source_type ) {
// There absolutely should be old keys, new keys, some removed/moved, and item prefix data.
if ( empty( $keys ) || empty( $new_keys ) || empty( $removed_keys ) || empty( $items ) ) {
return;
}
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
foreach ( $keys as $source_id => $source_keys ) {
if ( empty( $items[ $source_id ] ) || empty( $new_keys[ $source_id ] ) || empty( $removed_keys[ $source_id ] ) ) {
continue;
}
// As long as none of the new keys have been removed (roll-back),
// then we're all good to update the primary path and private prefix.
if ( ! empty( array_intersect( $new_keys[ $source_id ], $removed_keys[ $source_id ] ) ) ) {
continue;
}
$as3cf_item = $class::get_by_source_id( $source_id );
$extra_info = $as3cf_item->extra_info();
$extra_info['private_prefix'] = $items[ $source_id ]['private_prefix'];
$as3cf_item->set_extra_info( $extra_info );
$as3cf_item->update_path_prefix( $items[ $source_id ]['prefix'] );
$as3cf_item->save();
}
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished moving media files to new paths.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use AS3CF_Error;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use Exception;
class Move_Private_Objects_Process extends Move_Objects_Process {
/**
* @var string
*/
protected $action = 'move_private_objects';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$items_to_move = array();
if ( $this->as3cf->private_prefix_enabled() ) {
$new_private_prefix = $this->as3cf->get_setting( 'signed-urls-object-prefix' );
} else {
$new_private_prefix = '';
}
foreach ( $source_ids as $source_id ) {
$as3cf_item = $class::get_by_source_id( $source_id );
if ( $as3cf_item ) {
// Analyze current private prefix to see if it needs changing.
switch ( $this->should_move_to_new_private_prefix( $as3cf_item, $as3cf_item->private_prefix(), $new_private_prefix ) ) {
case self::MOVE_NO:
case self::MOVE_SAME:
break;
case self::MOVE_NOOP:
// If nothing is to be moved to new private prefix, just fix data.
$as3cf_item->set_private_prefix( $new_private_prefix );
$as3cf_item->save();
continue 2;
case self::MOVE_YES:
$items_to_move[ $source_id ] = array( 'prefix' => $as3cf_item->normalized_path_dir(), 'private_prefix' => $new_private_prefix );
break;
}
} else {
$name = $this->as3cf->get_source_type_name( $source_type );
AS3CF_Error::log( sprintf( 'Move Private Objects: Offload data for %s item with ID %d could not be found for analysis.', $name, $source_id ) );
}
}
$this->move_items( $items_to_move, $blog_id, $source_type );
// Whether moved or not, we processed every item.
return $source_ids;
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished moving media files to new private paths.', 'amazon-s3-and-cloudfront' );
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use AS3CF_Error;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use Exception;
class Move_Public_Objects_Process extends Move_Objects_Process {
/**
* @var string
*/
protected $action = 'move_public_objects';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
$items_to_move = array();
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
foreach ( $source_ids as $source_id ) {
$as3cf_item = $class::get_by_source_id( $source_id );
if ( $as3cf_item ) {
// Analyze current path to see if it needs changing.
$old_prefix = $as3cf_item->normalized_path_dir();
$new_prefix = $this->get_new_public_prefix( $as3cf_item, $old_prefix );
if ( $new_prefix !== $old_prefix ) {
$items_to_move[ $source_id ] = array( 'prefix' => $new_prefix, 'private_prefix' => $as3cf_item->private_prefix() );
}
} else {
$name = $this->as3cf->get_source_type_name( $source_type );
AS3CF_Error::log( sprintf( 'Move Public Objects: Offload data for %s item with ID %d could not be found for analysis.', $name, $source_id ) );
}
}
$this->move_items( $items_to_move, $blog_id, $source_type );
// Whether moved or not, we processed every item.
return $source_ids;
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished moving media files to new storage paths.', 'amazon-s3-and-cloudfront' );
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Items\Remove_Local_Handler;
use Exception;
class Remove_Local_Files_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'remove_local_files';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
$processed = $source_ids;
$remove_local_handler = $this->as3cf->get_item_handler( Remove_Local_Handler::get_item_handler_key_name() );
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
foreach ( $source_ids as $source_id ) {
/** @var Item $as3cf_item */
$as3cf_item = $class::get_by_source_id( $source_id );
if ( empty( $as3cf_item ) ) {
continue;
}
if ( ! $as3cf_item->served_by_provider( true ) ) {
continue;
}
if ( ! $as3cf_item->exists_locally() ) {
continue;
}
$remove_local_handler->handle( $as3cf_item );
}
// Whether removed from local or not, we processed every item.
return $processed;
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished removing media files from local server.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use AS3CF_Error;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use Exception;
class Update_ACLs_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'update_acls';
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
$bucket = $this->as3cf->get_setting( 'bucket' );
$region = $this->as3cf->get_setting( 'region' );
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$attachments_to_update = array();
foreach ( $source_ids as $source_id ) {
$as3cf_item = $class::get_by_source_id( $source_id );
if ( empty( $as3cf_item ) ) {
$name = $this->as3cf->get_source_type_name( $source_type );
AS3CF_Error::log( sprintf( 'Update Object ACLs: Offload data for %s item with ID %d could not be found for analysis.', $name, $source_id ) );
continue;
}
// If the attachment is offloaded to another provider, skip it.
if ( ! $as3cf_item->served_by_provider( true ) ) {
continue;
}
// If the attachment is offloaded to another bucket, skip it, because we don't know its Block Public Access state.
if ( $as3cf_item->bucket() !== $bucket ) {
continue;
}
$attachments_to_update[] = $source_id;
}
$this->update_items( $source_type, $attachments_to_update, $blog_id, $bucket, $region );
// Whether updated or not, we processed every item.
return $source_ids;
}
/**
* Bulk update ACLs for items.
*
* @param string $source_type
* @param array $items
* @param int $blog_id
* @param string $bucket
* @param string $region
*
* @throws Exception
*/
protected function update_items( $source_type, $items, $blog_id, $bucket, $region ) {
if ( empty( $items ) ) {
return;
}
$keys = $this->as3cf->get_provider_keys( $items, $source_type );
if ( empty( $keys ) ) {
return;
}
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$items_to_update = array();
foreach ( $keys as $source_id => $source_keys ) {
$as3cf_item = $class::get_by_source_id( $source_id );
foreach ( $source_keys as $key ) {
$size = $as3cf_item->get_object_key_from_filename( $key );
$acl = $as3cf_item->get_acl_for_object_key( $size );
// Only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
// This is a fallback check, just in case settings changed from under us via define etc, saves throwing lots of errors.
if ( ! empty( $acl ) ) {
$items_to_update[] = array(
'Bucket' => $bucket,
'Key' => $key,
'ACL' => $acl,
);
}
}
}
$failures = array();
if ( ! empty( $items_to_update ) ) {
$client = $this->as3cf->get_provider_client( $region, true );
try {
$failures = $client->update_object_acls( $items_to_update );
} catch ( Exception $e ) {
AS3CF_Error::log( $e->getMessage() );
return;
}
}
if ( ! empty( $failures ) ) {
$this->record_failures( $keys, $failures, $blog_id, $source_type );
}
}
/**
* Handle failed keys.
*
* @param array $keys
* @param array $failures
* @param int $blog_id
* @param string $source_type
*/
protected function record_failures( $keys, $failures, $blog_id, $source_type ) {
foreach ( $failures as $failure ) {
foreach ( $keys as $source_id => $source_keys ) {
if ( false !== array_search( $failure['Key'], $source_keys ) ) {
$error_msg = sprintf( __( 'Error updating object ACL for %1$s: %2$s', 'amazon-s3-and-cloudfront' ), $failure['Key'], $failure['Message'] );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
unset( $keys[ $source_id ] );
break;
}
}
}
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished updating object ACLs in bucket.', 'amazon-s3-and-cloudfront' );
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use Amazon_S3_And_CloudFront;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Items\Upload_Handler;
use Exception;
class Uploader_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'uploader';
/**
* @var int
*/
private $license_limit = -1;
/**
* @var int
*/
private $offloaded = 0;
/**
* Process items chunk.
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
$processed = array();
// With caching this may be some minutes behind, and may not include previous batches,
// but this really doesn't matter in the grand scheme of things as it'll eventually catch up.
$this->license_limit = $this->as3cf->get_total_allowed_media_items_to_upload();
foreach ( $source_ids as $source_id ) {
// Check we are allowed to carry on offloading.
if ( ! $this->should_upload_item( $source_id, $blog_id ) ) {
return $processed;
}
if ( $this->handle_item( $source_type, $source_id, $blog_id ) ) {
$this->offloaded++;
}
// Whether actually offloaded or not, we've processed the item.
$processed[] = $source_id;
}
return $processed;
}
/**
* Upload the item to provider.
*
* @param string $source_type
* @param int $source_id
* @param int $blog_id
*
* @return bool
* @throws Exception
*/
protected function handle_item( $source_type, $source_id, $blog_id ) {
/** @var Item $class */
$class = $this->as3cf->get_source_type_class( $source_type );
$as3cf_item = $class::get_by_source_id( $source_id );
// Skip item if item already on provider.
if ( $as3cf_item ) {
return false;
}
// Skip if we can't get a valid Item instance.
$as3cf_item = $class::create_from_source_id( $source_id );
if ( is_wp_error( $as3cf_item ) ) {
$this->record_error( $blog_id, $source_type, $source_id, $as3cf_item->get_error_message() );
return false;
}
$upload_handler = $this->as3cf->get_item_handler( Upload_Handler::get_item_handler_key_name() );
$upload_result = $upload_handler->handle( $as3cf_item );
// Build error message.
if ( is_wp_error( $upload_result ) ) {
if ( $this->count_errors() < 100 ) {
foreach ( $upload_result->get_error_messages() as $error_message ) {
$error_msg = sprintf( __( 'Error offloading to bucket - %s', 'amazon-s3-and-cloudfront' ), $error_message );
$this->record_error( $blog_id, $source_type, $source_id, $error_msg );
}
}
return false;
}
return true;
}
/**
* Check there is enough allowed items for the license before uploading.
*
* @param int $item_id
* @param int $blog_id
*
* @return bool
*/
protected function should_upload_item( $item_id, $blog_id ) {
// No limit, or not counting towards limit.
if ( 0 > $this->license_limit ) {
return true;
}
// If media limit met, cancel the offload and give notice.
if ( 0 >= ( $this->license_limit - $this->offloaded ) ) {
// Be really, really sure!
$this->as3cf->update_media_library_total();
$this->license_limit = $this->as3cf->get_total_allowed_media_items_to_upload();
if ( 0 === $this->license_limit ) {
$this->cancel();
$notice_id = $this->tool->get_tool_key() . '_license_limit';
$this->as3cf->notices->undismiss_notice_for_all( $notice_id );
$args = array(
'custom_id' => $notice_id,
'flash' => false,
'only_show_to_user' => false,
);
$this->as3cf->notices->add_notice( $this->get_reached_license_limit_message(), $args );
return false;
} else {
// Carry on!
$this->offloaded = 0;
}
}
return true;
}
/**
* Get reached license limit notice message.
*
* @return string
*/
protected function get_reached_license_limit_message() {
$account_link = sprintf( '<a href="%s" target="_blank">%s</a>', $this->as3cf->get_my_account_url(), __( 'My Account', 'amazon-s3-and-cloudfront' ) );
$notice_msg = __( "You've reached your license limit so we've had to stop your offload. To offload the rest of your media, please upgrade your license from %s and simply start the offload again. It will start from where it stopped.", 'amazon-s3-and-cloudfront' );
return sprintf( $notice_msg, $account_link );
}
/**
* Get blog items to process.
*
* @param string $source_type Item source type
* @param int $last_source_id
* @param int $limit Maximum number of item IDs to return
* @param bool $count Just return the count, negates $limit, default false
*
* @return array|int
*/
protected function get_blog_items( $source_type, $last_source_id, $limit, $count = false ) {
/** @var Amazon_S3_And_CloudFront $as3cf */
global $as3cf;
/** @var Item $class */
$class = $as3cf->get_source_type_class( $source_type );
if ( ! empty( $class ) ) {
return $class::get_missing_source_ids( $last_source_id, $limit, $count );
}
return $count ? 0 : array();
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
$this->as3cf->update_media_library_total();
}
/**
* Called when background process has been paused.
*/
protected function paused() {
$this->as3cf->update_media_library_total();
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
$this->as3cf->update_media_library_total();
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished offloading media to bucket.', 'amazon-s3-and-cloudfront' );
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Background_Processes;
use DeliciousBrains\WP_Offload_Media\Integrations\Media_Library;
use DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item;
use DeliciousBrains\WP_Offload_Media\Pro\Integrations\Woocommerce;
use DeliciousBrains\WP_Offload_Media\Pro\Items\Update_Acl_Handler;
use Exception;
class Woocommerce_Product_Urls_Process extends Background_Tool_Process {
/**
* @var string
*/
protected $action = 'woocommerce_product_url';
/**
* Process chunk of products
*
* @param string $source_type
* @param array $source_ids
* @param int $blog_id
*
* @return array
*
* @throws Exception
*/
protected function process_items_chunk( $source_type, $source_ids, $blog_id ) {
foreach ( $source_ids as $source_id ) {
$this->handle_attachment( $source_id, $blog_id );
}
// We processed every item.
return $source_ids;
}
/**
* Process individual product or product variations by looking for downloadable
* files directly in the products meta data.
*
* @param int $product_id
* @param int $blog_id
*
* @return bool
* @throws Exception
*/
protected function handle_attachment( $product_id, $blog_id ) {
/** @var Media_Library $media_library */
$media_library = $this->as3cf->get_integration_manager()->get_integration( 'mlib' );
$woocommerce = new Woocommerce( $this->as3cf );
$acl_handler = $this->as3cf->get_item_handler( Update_Acl_Handler::get_item_handler_key_name() );
// Get all the downloadable files for this post. Straight
// from the DB to avoid filters
$downloads = get_post_meta( $product_id, '_downloadable_files', true );
// If we don't get an array, there's nothing we can do with
// this product / variation
if ( ! $downloads || ! is_array( $downloads ) ) {
return true;
}
$updated = false;
foreach ( $downloads as &$download ) {
$stored_file = $download['file'];
$size = null;
$update_needed = false;
// Is this our shortcode?
$attachment_id = $woocommerce->get_attachment_id_from_shortcode( $stored_file );
if ( $attachment_id ) {
$atts = $woocommerce->get_shortcode_atts( $stored_file );
$update_needed = isset( $atts['id'] );
}
// Is this a local URL?
if ( false === $attachment_id ) {
$attachment_id = $media_library->get_attachment_id_from_local_url( $stored_file );
}
// Is it a remote URL we recognize?
if ( false === $attachment_id ) {
$attachment_id = $media_library->get_attachment_id_from_provider_url( $stored_file );
$update_needed = true;
}
// If we can't identify an offloaded item we have to give up
if ( false === $attachment_id ) {
continue;
}
$as3cf_item = Media_Library_Item::get_by_source_id( $attachment_id );
// We couldn't find an item for this $attachment_id
if ( ! $as3cf_item ) {
continue;
}
// Ensure that it's private on the provider
$size = $this->as3cf->filter_local->get_size_string_from_url( $as3cf_item->get_item_source_array(), $stored_file );
$options = array(
'object_keys' => array( $size ),
'set_private' => true,
);
$result = $acl_handler->handle( $as3cf_item, $options );
if ( is_wp_error( $result ) ) {
$error_msg = sprintf( __( 'Error updating object ACL for media library item %s', 'amazon-s3-and-cloudfront' ), $attachment_id );
$this->record_error( $blog_id, Media_Library_Item::source_type(), $attachment_id, $error_msg );
}
// If this is not a local URL already, we update:
if ( $update_needed ) {
$download['file'] = $as3cf_item->get_local_url( $size );
$updated = true;
}
}
if ( $updated ) {
update_post_meta( $product_id, '_downloadable_files', $downloads );
}
return true;
}
/**
* Find all product and/or product variation IDs that have a _downloadable_files
* meta data item.
*
* @param string $source_type Item source type
* @param int $last_product_id The ID of the last item previously processed
* @param int $limit Maximum number of product IDs to return
* @param bool $count Just return the count, negates $limit, default false
*
* @return array|int
*/
protected function get_blog_items( $source_type, $last_product_id, $limit, $count = false ) {
global $wpdb;
$args = array();
if ( $count ) {
$sql = 'SELECT COUNT(DISTINCT post_id)';
} else {
$sql = 'SELECT DISTINCT post_id';
}
$sql .= " FROM {$wpdb->postmeta} ";
$sql .= " WHERE meta_key='_downloadable_files' ";
if ( ! empty( $last_product_id ) ) {
$sql .= ' AND post_id < %d';
$args[] = $last_product_id;
}
if ( ! $count ) {
$sql .= ' ORDER BY post_id DESC LIMIT %d';
$args[] = $limit;
}
if ( count( $args ) > 0 ) {
$sql = $wpdb->prepare( $sql, $args );
}
if ( $count ) {
return $wpdb->get_var( $sql );
} else {
return array_map( 'intval', $wpdb->get_col( $sql ) );
}
}
/**
* Called when background process has been cancelled.
*/
protected function cancelled() {
// Do nothing at the moment.
}
/**
* Called when background process has been paused.
*/
protected function paused() {
// Do nothing at the moment.
}
/**
* Called when background process has been resumed.
*/
protected function resumed() {
// Do nothing at the moment.
}
/**
* Called when background process has completed.
*/
protected function completed() {
// Do nothing at the moment.
}
/**
* Get complete notice message.
*
* @return string
*/
protected function get_complete_message() {
return __( 'Finished updating and verifying WooCommerce downloads.', 'amazon-s3-and-cloudfront' );
}
}