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,174 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
use DeliciousBrains\WP_Offload_Media\Providers\Storage\Storage_Provider;
use Exception;
use WP_Error;
class Download_Handler extends Item_Handler {
/**
* @var string
*/
protected static $item_handler_key = 'download';
/**
* The default options that should be used if none supplied.
*
* @return array
*/
public static function default_options() {
return array(
'full_source_paths' => array(),
);
}
/**
* Prepare a manifest based on the item.
*
* @param Item $as3cf_item
* @param array $options
*
* @return Manifest
*/
protected function pre_handle( Item $as3cf_item, array $options ) {
$manifest = new Manifest();
$file_paths = array();
foreach ( $as3cf_item->objects() as $object_key => $object ) {
$file = $as3cf_item->full_source_path( $object_key );
if ( 0 < count( $options['full_source_paths'] ) && ! in_array( $file, $options['full_source_paths'] ) ) {
continue;
}
$file_paths[ $object_key ] = $file;
}
$file_paths = array_unique( $file_paths );
foreach ( $file_paths as $object_key => $file_path ) {
if ( ! file_exists( $file_path ) ) {
$manifest->objects[] = array(
'args' => array(
'Bucket' => $as3cf_item->bucket(),
'Key' => $as3cf_item->provider_key( $object_key ),
'SaveAs' => $file_path,
),
);
}
}
return $manifest;
}
/**
* Perform the downloads.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return boolean|WP_Error
* @throws Exception
*/
protected function handle_item( Item $as3cf_item, Manifest $manifest, array $options ) {
if ( ! empty( $manifest->objects ) ) {
// This test is "late" so that we don't raise the error if the local files exist anyway.
// If the provider of this item is different from what's currently configured,
// we'll return an error.
$current_provider = $this->as3cf->get_storage_provider();
if ( ! is_null( $current_provider ) && $current_provider::get_provider_key_name() !== $as3cf_item->provider() ) {
$error_msg = sprintf(
__( '%1$s with ID %2$d is offloaded to a different provider than currently configured', 'amazon-s3-and-cloudfront' ),
$this->as3cf->get_source_type_name( $as3cf_item->source_type() ),
$as3cf_item->source_id()
);
return $this->return_handler_error( $error_msg );
} else {
$provider_client = $this->as3cf->get_provider_client( $as3cf_item->region() );
foreach ( $manifest->objects as &$manifest_object ) {
// Save object to a file.
$result = $this->download_object( $provider_client, $manifest_object['args'] );
$manifest_object['download_result']['status'] = self::STATUS_OK;
if ( is_wp_error( $result ) ) {
$manifest_object['download_result']['status'] = self::STATUS_FAILED;
$manifest_object['download_result']['message'] = $result->get_error_message();
}
}
}
}
return true;
}
/**
* Perform post handle tasks. Log errors, update filesize totals etc.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool|WP_Error
*/
protected function post_handle( Item $as3cf_item, Manifest $manifest, array $options ) {
$error_count = 0;
foreach ( $manifest->objects as $manifest_object ) {
if ( self::STATUS_OK !== $manifest_object['download_result']['status'] ) {
$error_count++;
}
}
if ( $error_count > 0 ) {
$error_message = sprintf(
__( 'There were %1$d errors downloading files for %2$s ID %3$d from bucket', 'amazon-s3-and-cloudfront' ),
$error_count,
$this->as3cf->get_source_type_name( $as3cf_item->source_type() ),
$as3cf_item->source_id()
);
return new WP_Error( 'download-error', $error_message );
}
$as3cf_item->update_filesize_after_download_local();
return true;
}
/**
* Download an object from provider.
*
* @param Storage_Provider $provider_client
* @param array $object
*
* @return bool|WP_Error
*/
private function download_object( $provider_client, $object ) {
// Make sure the local directory exists.
$dir = dirname( $object['SaveAs'] );
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
$error_msg = sprintf( __( 'The local directory %s does not exist and could not be created.', 'amazon-s3-and-cloudfront' ), $dir );
$error_msg = sprintf( __( 'There was an error attempting to download the file %1$s from the bucket: %2$s', 'amazon-s3-and-cloudfront' ), $object['Key'], $error_msg );
return $this->return_handler_error( $error_msg );
}
try {
$provider_client->get_object( $object );
} catch ( Exception $e ) {
// If storage provider file doesn't exist, an empty local file will be created, clean it up.
@unlink( $object['SaveAs'] ); //phpcs:ignore
$error_msg = sprintf( __( 'Error downloading %1$s from bucket: %2$s', 'amazon-s3-and-cloudfront' ), $object['Key'], $e->getMessage() );
return $this->return_handler_error( $error_msg );
}
return true;
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
use Amazon_S3_And_CloudFront;
use AS3CF_Error;
use Exception;
use WP_Error;
/**
* Class Item_Handler
*
* Base class for item handler classes.
*
* @package DeliciousBrains\WP_Offload_Media\Items
*/
abstract class Item_Handler {
/**
* Status codes
*/
const STATUS_OK = 'ok';
const STATUS_FAILED = 'failed';
/**
* @var string
*/
protected static $item_handler_key;
/**
* @var Amazon_S3_And_CloudFront
*/
protected $as3cf;
/**
* AS3CF_Item_Handler constructor.
*
* @param Amazon_S3_And_CloudFront $as3cf
*/
public function __construct( Amazon_S3_And_CloudFront $as3cf ) {
$this->as3cf = $as3cf;
}
/**
* Get the item handler key name.
*
* @return string
*/
public static function get_item_handler_key_name() {
return static::$item_handler_key;
}
/**
* The default options that should be used if none supplied.
*
* @return array
*/
public static function default_options() {
return array();
}
/**
* Main entrypoint for handling an item.
*
* @param Item $as3cf_item
* @param array $options
*
* @return boolean|WP_Error
*/
public function handle( Item $as3cf_item, array $options = array() ) {
// Merge supplied option values into the defaults as long as supplied options are recognised.
if ( empty( $options ) || ! is_array( $options ) ) {
$options = array();
}
$options = array_merge( $this->default_options(), array_intersect_key( $options, $this->default_options() ) );
try {
/**
* Filter fires before handling an action on an item, allows action to be cancelled.
*
* This is a generic handler filter that includes the handler's key name as the last param.
*
* @param bool $cancel Should the action on the item be cancelled?
* @param Item $as3cf_item The item that the action is being handled for.
* @param array $options Handler dependent options that may have been set for the action.
* @param array $handler_key_name The handler's key name as per `Item_Handler::get_item_handler_key_name()`.
*
* @see Item_Handler::get_item_handler_key_name()
*/
$cancel = apply_filters(
'as3cf_pre_handle_item',
/**
* Filter fires before handling an action on an item, allows action to be cancelled.
*
* This is a handler specific filter whose name ends with the handler's key name.
* Format is `as3cf_pre_handle_item_{item-handler-key-name}`.
*
* Example filter names:
*
* as3cf_pre_handle_item_upload
* as3cf_pre_handle_item_download
* as3cf_pre_handle_item_remove-local
* as3cf_pre_handle_item_remove-provider
* as3cf_pre_handle_item_update-acl
*
* For a more generic filter, use `as3cf_pre_handle_item`.
*
* @param bool $cancel Should the action on the item be cancelled?
* @param Item $as3cf_item The item that the action is being handled for.
* @param array $options Handler dependent options that may have been set for the action.
*
* @see Item_Handler::get_item_handler_key_name()
*/
apply_filters( 'as3cf_pre_handle_item_' . static::get_item_handler_key_name(), false, $as3cf_item, $options ),
$as3cf_item,
$options,
static::get_item_handler_key_name()
);
} catch ( Exception $e ) {
return $this->return_result( new WP_Error( $e->getMessage() ), $as3cf_item, $options );
}
// Cancelled, let caller know that request was not handled.
if ( false !== $cancel ) {
// If something unexpected happened, let the caller know.
if ( is_wp_error( $cancel ) ) {
return $this->return_result( $cancel, $as3cf_item, $options );
}
return $this->return_result( false, $as3cf_item, $options );
}
$manifest = $this->pre_handle( $as3cf_item, $options );
if ( is_wp_error( $manifest ) ) {
return $this->return_result( $manifest, $as3cf_item, $options );
}
// Nothing to do, let caller know that request was not handled.
if ( empty( $manifest ) || empty( $manifest->objects ) ) {
return $this->return_result( false, $as3cf_item, $options );
}
$result = $this->handle_item( $as3cf_item, $manifest, $options );
if ( is_wp_error( $result ) ) {
return $this->return_result( $result, $as3cf_item, $options );
}
$result = $this->post_handle( $as3cf_item, $manifest, $options );
return $this->return_result( $result, $as3cf_item, $options );
}
/**
* Process an Item and options to generate a Manifest for `handle_item`.
*
* @param Item $as3cf_item
* @param array $options
*
* @return Manifest|WP_Error
*/
abstract protected function pre_handle( Item $as3cf_item, array $options );
/**
* Perform action for Item using given Manifest.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool|WP_Error
*/
abstract protected function handle_item( Item $as3cf_item, Manifest $manifest, array $options );
/**
* Process results of `handle_item` as appropriate.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool|WP_Error
*/
abstract protected function post_handle( Item $as3cf_item, Manifest $manifest, array $options );
/**
* Helper to record errors and return them or optional supplied value.
*
* @param string|WP_Error $error_msg An error message or already constructed WP_Error.
* @param mixed|null $return Optional return value instead of WP_Error.
*
* @return mixed|WP_Error
*/
protected function return_handler_error( $error_msg, $return = null ) {
if ( is_wp_error( $error_msg ) ) {
foreach ( $error_msg->get_error_messages() as $msg ) {
AS3CF_Error::Log( $msg );
}
} else {
AS3CF_Error::log( $error_msg );
}
if ( is_null( $return ) ) {
return is_wp_error( $error_msg ) ? $error_msg : new WP_Error( 'exception', $error_msg );
}
return $return;
}
/**
* Fires a couple of actions to let interested parties know that a handler has returned a result.
*
* @param bool|WP_Error $result Result for the action, either handled (true/false), or an error.
* @param Item $as3cf_item The item that the action was being handled for.
* @param array $options Handler dependent options that may have been set for the action.
*
* @return bool|WP_Error
*/
private function return_result( $result, Item $as3cf_item, array $options ) {
/**
* Action fires after attempting to handle an action on an item.
*
* This is a handler specific action whose name ends with the handler's key name.
* Format is `as3cf_post_handle_item_{item-handler-key-name}`.
*
* Example filter names:
*
* as3cf_post_handle_item_upload
* as3cf_post_handle_item_download
* as3cf_post_handle_item_remove-local
* as3cf_post_handle_item_remove-provider
* as3cf_post_handle_item_update-acl
*
* For a more generic filter, use `as3cf_post_handle_item`.
*
* @param bool|WP_Error $result Result for the action, either handled (true/false), or an error.
* @param Item $as3cf_item The item that the action was being handled for.
* @param array $options Handler dependent options that may have been set for the action.
*
* @see Item_Handler::get_item_handler_key_name()
*/
do_action( 'as3cf_post_handle_item_' . static::get_item_handler_key_name(), $result, $as3cf_item, $options );
/**
* Action fires after attempting to handle an action on an item.
*
* This is a generic handler action that includes the handler's key name as the last param.
*
* @param bool|WP_Error $result Result for the action, either handled (true/false), or an error.
* @param Item $as3cf_item The item that the action was being handled for.
* @param array $options Handler dependent options that may have been set for the action.
* @param array $handler_key_name The handler's key name as per `Item_Handler::get_item_handler_key_name()`.
*
* @see Item_Handler::get_item_handler_key_name()
*/
do_action( 'as3cf_post_handle_item', $result, $as3cf_item, $options, static::get_item_handler_key_name() );
return $result;
}
}

2197
classes/items/item.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
class Manifest {
/**
* @var array
*/
public $objects = array();
}

View File

@@ -0,0 +1,837 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
use Amazon_S3_And_CloudFront;
use AS3CF_Utils;
use WP_Error;
class Media_Library_Item extends Item {
/**
* Source type name
*
* @var string
*/
protected static $source_type_name = 'Media Library Item';
/**
* Internal source type identifier
*
* @var string
*/
protected static $source_type = 'media-library';
/**
* Table that corresponds to this item type
*
* @var string
*/
protected static $source_table = 'posts';
/**
* Foreign key (if any) in the $source_table
*
* @var string
*/
protected static $source_fk = 'id';
/**
* Item's summary type name.
*
* @var string
*/
protected static $summary_type_name = 'Media Library';
/**
* Item's summary type.
*
* @var string
*/
protected static $summary_type = 'media-library';
/**
* Item constructor.
*
* @param string $provider Storage provider key name, e.g. "aws".
* @param string $region Region for item's bucket.
* @param string $bucket Bucket for item.
* @param string $path Key path for item (full sized if type has thumbnails etc).
* @param bool $is_private Is the object private in the bucket.
* @param int $source_id ID that source has.
* @param string $source_path Path that source uses, could be relative or absolute depending on source.
* @param string $original_filename An optional filename with no path that was previously used for the item.
* @param array $extra_info An optional associative array of extra data to be associated with the item.
* Recognised keys:
* 'objects' => array of ...
* -- 'thumbnail' => array of ...
* -- -- 'source_file' => 'image-150x150.png'
* -- -- 'is_private' => false
* 'private_prefix' => 'private/'
* For backwards compatibility, if a simple array is supplied it is treated as
* private thumbnail sizes that should be private objects in the bucket.
* @param int $id Optional Item record ID.
* @param int $originator Optional originator of record from ORIGINATORS const.
* @param bool $is_verified Optional flag as to whether Item's objects are known to exist.
* @param bool $use_object_versioning Optional flag as to whether path prefix should use Object Versioning if type allows it.
*/
public function __construct(
$provider,
$region,
$bucket,
$path,
$is_private,
$source_id,
$source_path,
$original_filename = null,
$extra_info = array(),
$id = null,
$originator = 0,
$is_verified = true,
$use_object_versioning = self::CAN_USE_OBJECT_VERSIONING
) {
// For Media Library items, the source path should be relative to the Media Library's uploads directory.
$uploads = wp_upload_dir();
if ( false === $uploads['error'] && 0 === strpos( $source_path, $uploads['basedir'] ) ) {
$source_path = AS3CF_Utils::unleadingslashit( substr( $source_path, strlen( $uploads['basedir'] ) ) );
}
$objects = array();
$private_prefix = null;
// Ensure re-hydration is clean.
if ( ! empty( $extra_info ) && is_array( $extra_info ) ) {
if ( isset( $extra_info['private_prefix'] ) ) {
$private_prefix = $extra_info['private_prefix'];
}
if ( isset( $extra_info['objects'] ) ) {
$objects = $extra_info['objects'];
}
}
$extra_info = array(
'objects' => $objects,
'private_prefix' => $private_prefix,
);
parent::__construct( $provider, $region, $bucket, $path, $is_private, $source_id, $source_path, $original_filename, $extra_info, $id, $originator, $is_verified, $use_object_versioning );
}
/**
* Synthesize a data struct to be used when passing information
* about the current item to filters that assume the item is a
* media library item.
*
* @return array
*/
public function item_data_for_acl_filter() {
$item_data = parent::item_data_for_acl_filter();
$media_library_item_data = wp_get_attachment_metadata( $this->source_id(), true );
// Copy over specific elements only as i.e. 'size' may not be populated yet in $media_library_item_data
foreach ( array( 'file', 'original_image', 'image_meta' ) as $element ) {
if ( isset( $media_library_item_data[ $element ] ) ) {
$item_data[ $element ] = $media_library_item_data[ $element ];
}
}
return $item_data;
}
/**
* Create a new item from the source id.
*
* @param int $source_id
* @param array $options
*
* @return Item|WP_Error
*/
public static function create_from_source_id( $source_id, $options = array() ) {
if ( empty( $source_id ) ) {
return new WP_Error(
'exception',
__( 'Empty Attachment ID passed to ' . __FUNCTION__, 'amazon-s3-and-cloudfront' )
);
}
$default_options = array(
'originator' => Item::ORIGINATORS['standard'],
'is_verified' => true,
'use_object_versioning' => static::can_use_object_versioning(),
);
$options = array_merge( $default_options, $options );
if ( ! in_array( $options['originator'], self::ORIGINATORS ) ) {
return new WP_Error(
'exception',
__( 'Invalid Originator passed to ' . __FUNCTION__, 'amazon-s3-and-cloudfront' )
);
}
/*
* Derive local path.
*/
// Verify that get_attached_file will not blow up as it does not check the data it manipulates.
$attached_file_meta = get_post_meta( $source_id, '_wp_attached_file', true );
if ( ! is_string( $attached_file_meta ) ) {
return new WP_Error(
'exception',
sprintf( __( 'Media Library item with ID %d has damaged meta data', 'amazon-s3-and-cloudfront' ), $source_id )
);
}
unset( $attached_file_meta );
$source_path = get_attached_file( $source_id, true );
// Check for valid "full" file path otherwise we'll not be able to create offload path or download in the future.
if ( empty( $source_path ) ) {
return new WP_Error(
'exception',
sprintf( __( 'Media Library item with ID %d does not have a valid file path', 'amazon-s3-and-cloudfront' ), $source_id )
);
}
/** @var array|false|WP_Error $attachment_metadata */
$attachment_metadata = wp_get_attachment_metadata( $source_id, true );
if ( is_wp_error( $attachment_metadata ) ) {
return $attachment_metadata;
}
// Initialize extra info array with empty values
$extra_info = array(
'private_prefix' => null,
'objects' => array(),
);
// There may be an original image that can override the default original filename.
$original_filename = empty( $attachment_metadata['original_image'] ) ? null : $attachment_metadata['original_image'];
$file_paths = AS3CF_Utils::get_attachment_file_paths( $source_id, false, $attachment_metadata );
foreach ( $file_paths as $size => $size_file_path ) {
if ( $size === 'file' ) {
continue;
}
$new_object = array(
'source_file' => wp_basename( $size_file_path ),
'is_private' => false,
);
$extra_info['objects'][ $size ] = $new_object;
if ( empty( $original_filename ) && 'full-orig' === $size ) {
$original_filename = $new_object['source_file'];
}
}
return new self(
'',
'',
'',
'',
false,
$source_id,
$source_path,
$original_filename,
$extra_info,
null,
$options['originator'],
$options['is_verified'],
$options['use_object_versioning']
);
}
/**
* Get attachment local URL.
*
* This is partly a direct copy of wp_get_attachment_url() from /wp-includes/post.php
* as we filter the URL in AS3CF and can't remove this filter using the current implementation
* of globals for class instances.
*
* @param string|null $object_key
*
* @return string|false
*/
public function get_local_url( $object_key = null ) {
/** @var Amazon_S3_And_CloudFront $as3cf */
global $as3cf;
$url = '';
// Get attached file.
if ( $file = get_post_meta( $this->source_id(), '_wp_attached_file', true ) ) {
// Get upload directory.
if ( ( $uploads = wp_upload_dir() ) && false === $uploads['error'] ) {
// Check that the upload base exists in the file location.
if ( 0 === strpos( $file, $uploads['basedir'] ) ) {
// Replace file location with url location.
$url = str_replace( $uploads['basedir'], $uploads['baseurl'], $file );
} elseif ( false !== strpos( $file, 'wp-content/uploads' ) ) {
$url = $uploads['baseurl'] . substr( $file, strpos( $file, 'wp-content/uploads' ) + 18 );
} else {
// It's a newly-uploaded file, therefore $file is relative to the basedir.
$url = $uploads['baseurl'] . "/$file";
}
}
}
if ( empty( $url ) ) {
return false;
}
$url = $as3cf->maybe_fix_local_subsite_url( $url );
if ( ! empty( $object_key ) ) {
$meta = get_post_meta( $this->source_id(), '_wp_attachment_metadata', true );
if ( empty( $meta['sizes'][ $object_key ]['file'] ) ) {
// No alternative sizes available, return
return $url;
}
$url = str_replace( wp_basename( $url ), $meta['sizes'][ $object_key ]['file'], $url );
}
return $url;
}
/**
* Get the item based on source id.
*
* @param int $source_id
*
* @return bool|Media_Library_Item
*/
public static function get_by_source_id( $source_id ) {
$as3cf_item = parent::get_by_source_id( $source_id );
if ( ! $as3cf_item ) {
$provider_object = static::_legacy_get_attachment_provider_info( $source_id );
if ( is_array( $provider_object ) ) {
$as3cf_item = static::_legacy_provider_info_to_item( $source_id, $provider_object );
}
}
return $as3cf_item;
}
/**
* Full key (path) for given file that belongs to offloaded attachment.
*
* If no filename given, full sized path returned.
* Path is prepended with private prefix if size associated with filename is private,
* and a private prefix has been assigned to offload.
*
* @param string|null $filename
*
* @return string
*/
public function key( $filename = null ) {
// Public full path.
if ( empty( $filename ) && empty( $this->private_prefix() ) ) {
return parent::path();
}
if ( empty( $filename ) ) {
$filename = wp_basename( parent::path() );
}
if ( ! empty( $this->private_prefix() ) ) {
$size = $this->get_object_key_from_filename( $filename );
// Private path.
if ( $this->is_private( $size ) ) {
return $this->private_prefix() . $this->normalized_path_dir() . $filename;
}
}
// Public path.
return $this->normalized_path_dir() . $filename;
}
/**
* Get absolute source file paths for offloaded files.
*
* @return array Associative array of object_key => path
*/
public function full_source_paths() {
return array_intersect_key( AS3CF_Utils::get_attachment_file_paths( $this->source_id(), false ), $this->objects() );
}
/**
* Get size name from file name
*
* @param string $filename
*
* @return string
*/
public function get_object_key_from_filename( $filename ) {
return AS3CF_Utils::get_intermediate_size_from_filename( $this->source_id(), basename( $filename ) );
}
/**
* Get ACL for intermediate size.
*
* @param string $object_key Size name
* @param string|null $bucket Optional bucket that ACL is potentially to be used with.
*
* @return string|null
*/
public function get_acl_for_object_key( $object_key, $bucket = null ) {
/** @var Amazon_S3_And_CloudFront $as3cf */
global $as3cf;
return $as3cf->get_acl_for_intermediate_size( $this->source_id(), $object_key, $bucket, $this );
}
/**
* Get an array of un-managed source_ids in descending order.
*
* While source id isn't strictly unique, it is by source type, which is always used in queries based on called class.
*
* @param int $upper_bound Returned source_ids should be lower than this, use null/0 for no upper bound.
* @param int $limit Maximum number of source_ids to return. Required if not counting.
* @param bool $count Just return a count of matching source_ids? Negates $limit, default false.
*
* @return array|int
*/
public static function get_missing_source_ids( $upper_bound, $limit, $count = false ) {
global $wpdb;
$args = array( static::$source_type );
if ( $count ) {
$sql = 'SELECT COUNT(DISTINCT posts.ID)';
} else {
$sql = 'SELECT DISTINCT posts.ID';
}
$sql .= "
FROM {$wpdb->posts} AS posts
WHERE posts.post_type = 'attachment'
AND posts.ID NOT IN (
SELECT items.source_id
FROM " . static::items_table() . " AS items
WHERE items.source_type = %s
AND items.source_id = posts.ID
)
";
if ( ! empty( $upper_bound ) ) {
$sql .= ' AND posts.ID < %d';
$args[] = $upper_bound;
}
/**
* Allow users to exclude certain MIME types from attachments to upload.
*
* @param array
*/
$ignored_mime_types = apply_filters( 'as3cf_ignored_mime_types', array() );
if ( is_array( $ignored_mime_types ) && ! empty( $ignored_mime_types ) ) {
$ignored_mime_types = array_map( 'sanitize_text_field', $ignored_mime_types );
$sql .= " AND posts.post_mime_type NOT IN ('" . implode( "','", $ignored_mime_types ) . "')";
}
if ( ! $count ) {
$sql .= ' ORDER BY posts.ID DESC LIMIT %d';
$args[] = $limit;
}
$sql = $wpdb->prepare( $sql, $args );
if ( $count ) {
return (int) $wpdb->get_var( $sql );
} else {
return array_map( 'intval', $wpdb->get_col( $sql ) );
}
}
/**
* Finds Media Library items with same source_path and sets them as offloaded.
*/
public function offload_duplicate_items() {
global $wpdb;
$sql = $wpdb->prepare(
"
SELECT m.post_id
FROM " . $wpdb->postmeta . " AS m
LEFT JOIN " . $wpdb->posts . " AS p ON m.post_id = p.ID AND p.`post_type` = 'attachment'
WHERE m.meta_key = '_wp_attached_file'
AND m.meta_value = %s
AND m.post_id != %d
AND m.post_id NOT IN (
SELECT i.source_id
FROM " . static::items_table() . " AS i
WHERE i.source_type = %s
AND i.source_id = m.post_id
)
;
",
$this->source_path(),
$this->source_id(),
static::$source_type
);
$results = $wpdb->get_results( $sql );
// Nothing found, shortcut out.
if ( 0 === count( $results ) ) {
return;
}
foreach ( $results as $result ) {
$as3cf_item = new Media_Library_Item(
$this->provider(),
$this->region(),
$this->bucket(),
$this->path(),
$this->is_private(),
$result->post_id,
$this->source_path(),
wp_basename( $this->original_source_path() ),
$this->extra_info()
);
$as3cf_item->save();
$as3cf_item->duplicate_filesize_total( $this->source_id() );
}
}
/**
* Returns a link to the items edit page in WordPress
*
* @param object $error
*
* @return object|null Object containing url and link text
*/
public static function admin_link( $error ) {
return (object) array(
'url' => get_edit_post_link( $error->source_id, '' ),
'text' => __( 'Edit', 'amazon-s3-and-cloudfront' ),
);
}
/**
* Return a year/month string for the item
*
* @return string
*/
protected function get_item_time() {
return $this->get_attachment_folder_year_month();
}
/**
* Get the year/month string for attachment's upload.
*
* Fall back to post date if attached, otherwise current date.
*
* @param array $data
*
* @return string
*/
private function get_attachment_folder_year_month( $data = array() ) {
if ( empty( $data ) ) {
$data = wp_get_attachment_metadata( $this->source_id(), true );
}
if ( isset( $data['file'] ) ) {
$time = $this->get_folder_time_from_url( $data['file'] );
}
if ( empty( $time ) && ( $local_url = wp_get_attachment_url( $this->source_id() ) ) ) {
$time = $this->get_folder_time_from_url( $local_url );
}
if ( empty( $time ) ) {
$time = date( 'Y/m' );
if ( ! ( $attach = get_post( $this->source_id() ) ) ) {
return $time;
}
if ( ! $attach->post_parent ) {
return $time;
}
if ( ! ( $post = get_post( $attach->post_parent ) ) ) {
return $time;
}
if ( substr( $post->post_date_gmt, 0, 4 ) > 0 ) {
return date( 'Y/m', strtotime( $post->post_date_gmt . ' +0000' ) );
}
}
return $time;
}
/**
* Get the upload folder time from given URL
*
* @param string $url
*
* @return null|string YYYY/MM format.
*/
private function get_folder_time_from_url( $url ) {
if ( ! is_string( $url ) ) {
return null;
}
preg_match( '@[0-9]{4}/[0-9]{2}/@', $url, $matches );
if ( isset( $matches[0] ) ) {
return untrailingslashit( $matches[0] );
}
return null;
}
/**
* Returns filesize from metadata, if we have it, so that file or stream
* wrapper does not need to be hit.
*
* @return false|int
*/
public function get_filesize() {
// Prefer the canonical attachment filesize.
$metadata = get_post_meta( $this->source_id(), '_wp_attachment_metadata', true );
if ( ! empty( $metadata['filesize'] ) && is_int( $metadata['filesize'] ) ) {
return $metadata['filesize'];
}
// If offloaded and removed, we should have squirreled away the filesize.
$filesize = get_post_meta( $this->source_id(), 'as3cf_filesize_total', true );
if ( ! empty( $filesize ) && is_int( $filesize ) ) {
return $filesize;
}
return false;
}
/**
* Update filesize and as3cf_filesize_total metadata on the underlying media library item
* after removing the local file.
*
* @param int $original_size
* @param int $total_size
*/
public function update_filesize_after_remove_local( $original_size, $total_size ) {
update_post_meta( $this->source_id(), 'as3cf_filesize_total', $total_size );
if ( 0 < $original_size && ( $data = get_post_meta( $this->source_id(), '_wp_attachment_metadata', true ) ) ) {
if ( is_array( $data ) && empty( $data['filesize'] ) ) {
$data['filesize'] = $original_size;
// Update metadata with filesize
update_post_meta( $this->source_id(), '_wp_attachment_metadata', $data );
}
}
}
/**
* Cleanup filesize and as3cf_filesize_total metadata on the underlying media library item
* after downloading a file back from the bucket
*/
public function update_filesize_after_download_local() {
$data = get_post_meta( $this->source_id(), '_wp_attachment_metadata', true );
/*
* Audio and video have a filesize added to metadata by default, but images and anything else don't.
* Note: Could have used `wp_generate_attachment_metadata` here to test whether default metadata has 'filesize',
* but it not only has side effects it also does a lot of work considering it's not a huge deal for this entry to hang around.
*/
if (
! empty( $data ) &&
(
empty( $data['mime_type'] ) ||
0 === strpos( $data['mime_type'], 'image/' ) ||
! ( 0 === strpos( $data['mime_type'], 'audio/' ) || 0 === strpos( $data['mime_type'], 'video/' ) )
)
) {
unset( $data['filesize'] );
update_post_meta( $this->source_id(), '_wp_attachment_metadata', $data );
}
delete_post_meta( $this->source_id(), 'as3cf_filesize_total' );
}
/**
* Duplicate 'as3cf_filesize_total' meta if it exists for an attachment.
*
* @param int $attachment_id
*/
public function duplicate_filesize_total( $attachment_id ) {
if ( ! ( $filesize = get_post_meta( $attachment_id, 'as3cf_filesize_total', true ) ) ) {
// No filesize to duplicate.
return;
}
update_post_meta( $this->source_id(), 'as3cf_filesize_total', $filesize );
}
/**
* If another item in current site shares full size *local* paths, only remove remote files not referenced by duplicates.
* We reference local paths as they should be reflected one way or another remotely, including backups.
*
* @param Item $as3cf_item
* @param array $paths
*/
public function remove_duplicate_paths( Item $as3cf_item, $paths ) {
$full_size_paths = AS3CF_Utils::fullsize_paths( $as3cf_item->full_source_paths() );
$as3cf_items_with_paths = static::get_by_source_path( $full_size_paths, array( $as3cf_item->source_id() ), false );
$duplicate_paths = array();
foreach ( $as3cf_items_with_paths as $as3cf_item_with_path ) {
/* @var Media_Library_Item $as3cf_item_with_path */
$duplicate_paths += array_values( AS3CF_Utils::get_attachment_file_paths( $as3cf_item_with_path->source_id(), false, false, true ) );
}
if ( ! empty( $duplicate_paths ) ) {
$paths = array_diff( $paths, $duplicate_paths );
}
return $paths;
}
/**
* Returns the transient key to be used for storing blog specific item counts.
*
* @param int $blog_id
*
* @return string
*/
public static function transient_key_for_item_counts( int $blog_id ): string {
return 'as3cf_' . absint( $blog_id ) . '_attachment_counts';
}
/**
* Count total, offloaded and not offloaded items on current site.
*
* @return array Keys:
* total: Total media count for site (current blog id)
* offloaded: Count of offloaded media for site (current blog id)
* not_offloaded: Difference between total and offloaded
*/
protected static function get_item_counts(): array {
global $wpdb;
$sql = "SELECT count(id) FROM {$wpdb->posts} WHERE post_type = 'attachment'";
$attachment_count = (int) $wpdb->get_var( $sql );
$sql = 'SELECT count(id) FROM ' . static::items_table() . ' WHERE source_type = %s';
$sql = $wpdb->prepare( $sql, static::$source_type );
$offloaded_count = (int) $wpdb->get_var( $sql );
return array(
'total' => $attachment_count,
'offloaded' => $offloaded_count,
'not_offloaded' => max( $attachment_count - $offloaded_count, 0 ),
);
}
/*
* >>> LEGACY ROUTINES BEGIN >>>
*/
/**
* Convert the provider info array for an attachment to item object.
*
* phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
*
* @param int $source_id
* @param array $provider_info
*
* @return bool|Media_Library_Item
*/
private static function _legacy_provider_info_to_item( $source_id, $provider_info ) {
$attached_file = get_post_meta( $source_id, '_wp_attached_file', true );
if ( is_string( $attached_file ) && ! empty( $attached_file ) ) {
$private_sizes = array();
if ( ! empty( $provider_info['sizes'] ) && is_array( $provider_info['sizes'] ) ) {
$private_sizes = array_keys( $provider_info['sizes'] );
}
return new static(
$provider_info['provider'],
$provider_info['region'],
$provider_info['bucket'],
$provider_info['key'],
isset( $provider_info['acl'] ) && false !== strpos( $provider_info['acl'], 'private' ),
$source_id,
$attached_file,
wp_basename( $attached_file ),
$private_sizes
);
}
return false;
}
/**
* Get attachment provider info
*
* @param int $post_id
*
* @return bool|array
*/
private static function _legacy_get_attachment_provider_info( $post_id ) {
$provider_object = get_post_meta( $post_id, 'amazonS3_info', true );
if ( ! empty( $provider_object ) && is_array( $provider_object ) && ! empty( $provider_object['bucket'] ) && ! empty( $provider_object['key'] ) ) {
global $as3cf;
$provider_object = array_merge( array(
'provider' => $as3cf::get_default_storage_provider(),
), $provider_object );
} else {
return false;
}
$provider_object['region'] = static::_legacy_get_provider_object_region( $provider_object );
if ( is_wp_error( $provider_object['region'] ) ) {
return false;
}
$provider_object = apply_filters( 'as3cf_get_attachment_s3_info', $provider_object, $post_id ); // Backwards compatibility
return apply_filters( 'as3cf_get_attachment_provider_info', $provider_object, $post_id );
}
/**
* Get the region of the bucket stored in the provider metadata.
*
* @param array $provider_object
*
* @return string|WP_Error - region name
*/
private static function _legacy_get_provider_object_region( $provider_object ) {
if ( ! isset( $provider_object['region'] ) ) {
/** @var Amazon_S3_And_CloudFront $as3cf */
global $as3cf;
// If region hasn't been stored in the provider metadata, retrieve using the bucket.
$region = $as3cf->get_bucket_region( $provider_object['bucket'] );
// Could just return $region here regardless, but this format is good for debug during legacy migration.
if ( is_wp_error( $region ) ) {
return $region;
}
$provider_object['region'] = $region;
}
return $provider_object['region'];
}
/*
* <<< LEGACY ROUTINES END <<<
*/
}

View File

@@ -0,0 +1,44 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
use WP_Error;
class Provider_Test_Item extends Media_Library_Item {
/**
* Source type name.
*
* @var string
*/
protected static $source_type_name = 'Provider Test Item';
/**
* Internal source type identifier.
*
* @var string
*/
protected static $source_type = 'provider-test';
/**
* Overrides the parent implementation to avoid storing anything in the database.
*
* @param bool $update_duplicates
*
* @return int|WP_Error
*/
public function save( $update_duplicates = true ) {
return 0;
}
/**
* Overrides the parent implementation. Return all paths unchanged.
*
* @param Item $as3cf_item
* @param array $paths
*
* @return array
*/
public function remove_duplicate_paths( Item $as3cf_item, $paths ): array {
return $paths;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
use AS3CF_Error;
class Remove_Local_Handler extends Item_Handler {
/**
* @var string
*/
protected static $item_handler_key = 'remove-local';
/**
* Keep track of individual files we've already attempted to remove.
*
* @var array
*/
private $remove_blocked = array();
/**
* Keep track of size of individual files we've already attempted to remove.
*
* @var array
*/
private $removed_size = array();
/**
* If remove the primary file, we want to update the 'filesize'.
*
* @var int
*/
private $removed_primary_size = array();
/**
* The default options that should be used if none supplied.
*
* @return array
*/
public static function default_options() {
return array(
'verify_exists_on_provider' => false,
'provider_keys' => array(),
'files_to_remove' => array(),
);
}
/**
* Create manifest for local removal.
*
* @param Item $as3cf_item
* @param array $options
*
* @return Manifest
*/
protected function pre_handle( Item $as3cf_item, array $options ) {
$manifest = new Manifest();
$source_id = $as3cf_item->source_id();
$primary_file = '';
$files_to_remove = array();
// Note: Unable to use Item::full_size_paths() here
// as source item's metadata may not be up-to-date yet.
foreach ( $as3cf_item->objects() as $object_key => $object ) {
$file = $as3cf_item->full_source_path( $object_key );
if ( in_array( $file, $this->remove_blocked ) ) {
continue;
}
if ( 0 < count( $options['files_to_remove'] ) && ! in_array( $file, $options['files_to_remove'] ) ) {
continue;
}
// If needed, make sure this item exists among the provider keys.
if ( true === $options['verify_exists_on_provider'] ) {
if ( empty( $options['provider_keys'][ $source_id ] ) ) {
continue;
}
if ( ! in_array( $as3cf_item->provider_key( $object_key ), $options['provider_keys'][ $source_id ] ) ) {
continue;
}
}
if ( file_exists( $file ) ) {
$files_to_remove[] = $file;
if ( Item::primary_object_key() === $object_key ) {
$primary_file = $file;
}
}
}
/**
* Filters array of local files before being removed from server.
*
* @param array $files_to_remove Array of paths to be removed
* @param Item $as3cf_item The Item object
* @param array $item_source Item source descriptor array
*/
$filtered_files_to_remove = apply_filters( 'as3cf_remove_local_files', $files_to_remove, $as3cf_item, $as3cf_item->get_item_source_array() );
// Ensure fileset is unique and does not contain files already blocked.
$filtered_files_to_remove = array_unique( array_diff( $filtered_files_to_remove, $this->remove_blocked ) );
// If filter removes files from list, block attempts to remove them in later calls.
$this->remove_blocked = array_merge( $this->remove_blocked, array_diff( $files_to_remove, $filtered_files_to_remove ) );
foreach ( $filtered_files_to_remove as $file ) {
// Filter may have added some files to check for existence.
if ( ! in_array( $file, $files_to_remove ) ) {
if ( ! file_exists( $file ) ) {
continue;
}
}
/**
* Filter individual files that might still be kept local.
*
* @param bool $preserve Should the file be kept on the server?
* @param string $file Full path to the local file
*/
if ( false !== apply_filters( 'as3cf_preserve_file_from_local_removal', false, $file ) ) {
$this->remove_blocked[] = $file;
continue;
}
$manifest->objects[] = array(
'file' => $file,
'size' => filesize( $file ),
'is_primary' => $file === $primary_file,
);
}
return $manifest;
}
/**
* Delete local files described in the manifest object array.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool
*/
protected function handle_item( Item $as3cf_item, Manifest $manifest, array $options ) {
foreach ( $manifest->objects as &$file_to_remove ) {
$file = $file_to_remove['file'];
$file_to_remove['remove_result'] = array( 'status' => self::STATUS_OK );
//phpcs:ignore
if ( ! @unlink( $file ) ) {
$this->remove_blocked[] = $file;
$file_to_remove['remove_result']['status'] = self::STATUS_FAILED;
$file_to_remove['remove_result']['message'] = "Error removing local file at $file";
if ( ! file_exists( $file ) ) {
$file_to_remove['remove_result']['message'] = "Error removing local file. Couldn't find the file at $file";
} elseif ( ! is_writable( $file ) ) {
$file_to_remove['remove_result']['message'] = "Error removing local file. Ownership or permissions are mis-configured for $file";
}
}
}
return true;
}
/**
* Perform post handle tasks.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool
*/
protected function post_handle( Item $as3cf_item, Manifest $manifest, array $options ) {
if ( empty( $manifest->objects ) ) {
return true;
}
// Assume we didn't touch the primary file.
$this->removed_primary_size[ $as3cf_item->source_id() ] = 0;
foreach ( $manifest->objects as $file_to_remove ) {
if ( $file_to_remove['remove_result']['status'] !== self::STATUS_OK ) {
AS3CF_Error::log( $file_to_remove['remove_result']['message'] );
continue;
}
if ( empty( $this->removed_size[ $as3cf_item->source_id() ] ) ) {
$this->removed_size[ $as3cf_item->source_id() ] = $file_to_remove['size'];
} else {
$this->removed_size[ $as3cf_item->source_id() ] += $file_to_remove['size'];
}
if ( $file_to_remove['is_primary'] ) {
$this->removed_primary_size[ $as3cf_item->source_id() ] = $file_to_remove['size'];
}
}
$as3cf_item->update_filesize_after_remove_local( $this->removed_primary_size[ $as3cf_item->source_id() ], $this->removed_size[ $as3cf_item->source_id() ] );
return true;
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
use Exception;
use WP_Error;
class Remove_Provider_Handler extends Item_Handler {
/**
* @var string
*/
protected static $item_handler_key = 'remove-provider';
/**
* The default options that should be used if none supplied.
*
* @return array
*/
public static function default_options() {
return array(
'object_keys' => array(),
'offloaded_files' => array(),
);
}
/**
* Create manifest for removal from provider.
*
* @param Item $as3cf_item
* @param array $options
*
* @return Manifest|WP_Error
*/
protected function pre_handle( Item $as3cf_item, array $options ) {
$manifest = new Manifest();
$paths = array();
if ( ! empty( $options['object_keys'] ) && ! is_array( $options['object_keys'] ) ) {
return $this->return_handler_error( __( 'Invalid object_keys option provided.', 'amazon-s3-and-cloudfront' ) );
}
if ( ! empty( $options['offloaded_files'] ) && ! is_array( $options['offloaded_files'] ) ) {
return $this->return_handler_error( __( 'Invalid offloaded_files option provided.', 'amazon-s3-and-cloudfront' ) );
}
if ( ! empty( $options['object_keys'] ) && ! empty( $options['offloaded_files'] ) ) {
return $this->return_handler_error( __( 'Providing both object_keys and offloaded_files options is not supported.', 'amazon-s3-and-cloudfront' ) );
}
if ( empty( $options['offloaded_files'] ) ) {
foreach ( $as3cf_item->objects() as $object_key => $object ) {
if ( 0 < count( $options['object_keys'] ) && ! in_array( $object_key, $options['object_keys'] ) ) {
continue;
}
$paths[ $object_key ] = $as3cf_item->full_source_path( $object_key );
}
} else {
foreach ( $options['offloaded_files'] as $filename => $object ) {
$paths[ $filename ] = $as3cf_item->full_source_path_for_filename( $filename );
}
}
/**
* Filters array of source files before being removed from provider.
*
* @param array $paths Array of local paths to be removed from provider
* @param Item $as3cf_item The Item object
* @param array $item_source The item source descriptor array
*/
$paths = apply_filters( 'as3cf_remove_source_files_from_provider', $paths, $as3cf_item, $as3cf_item->get_item_source_array() );
$paths = array_unique( $paths );
// Remove local source paths that other items may have offloaded.
$paths = $as3cf_item->remove_duplicate_paths( $as3cf_item, $paths );
// Nothing to do, shortcut out.
if ( empty( $paths ) ) {
return $manifest;
}
if ( empty( $options['offloaded_files'] ) ) {
foreach ( $paths as $object_key => $path ) {
$manifest->objects[] = array(
'Key' => $as3cf_item->provider_key( $object_key ),
);
}
} else {
foreach ( $paths as $filename => $path ) {
$manifest->objects[] = array(
'Key' => $as3cf_item->provider_key_for_filename( $filename, $options['offloaded_files'][ $filename ]['is_private'] ),
);
}
}
return $manifest;
}
/**
* Delete provider objects described in the manifest object array
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool|WP_Error
*/
protected function handle_item( Item $as3cf_item, Manifest $manifest, array $options ) {
// This test is "late" so that we don't raise the error if there is nothing to remove.
// If the provider of this item is different from what's currently configured,
// we'll return an error.
$current_provider = $this->as3cf->get_storage_provider();
if ( ! is_null( $current_provider ) && $current_provider::get_provider_key_name() !== $as3cf_item->provider() ) {
$error_msg = sprintf(
__( '%1$s with ID %2$d is offloaded to a different provider than currently configured', 'amazon-s3-and-cloudfront' ),
$this->as3cf->get_source_type_name( $as3cf_item->source_type() ),
$as3cf_item->source_id()
);
return $this->return_handler_error( $error_msg );
}
$chunks = array_chunk( $manifest->objects, 1000 );
$region = $as3cf_item->region();
$bucket = $as3cf_item->bucket();
try {
foreach ( $chunks as $chunk ) {
$this->as3cf->get_provider_client( $region )->delete_objects( array(
'Bucket' => $bucket,
'Objects' => $chunk,
) );
}
} catch ( Exception $e ) {
$error_msg = sprintf( __( 'Error removing files from bucket: %s', 'amazon-s3-and-cloudfront' ), $e->getMessage() );
return $this->return_handler_error( $error_msg );
}
return true;
}
/**
* Perform post handle tasks.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool
*/
protected function post_handle( Item $as3cf_item, Manifest $manifest, array $options ) {
return true;
}
}

View File

@@ -0,0 +1,434 @@
<?php
namespace DeliciousBrains\WP_Offload_Media\Items;
use AS3CF_Error;
use AS3CF_Utils;
use DeliciousBrains\WP_Offload_Media\Providers\Storage\Storage_Provider;
use Exception;
use WP_Error;
class Upload_Handler extends Item_Handler {
/**
* @var string
*/
protected static $item_handler_key = 'upload';
/**
* Keep track of individual files we've already attempted to upload
*
* @var array
*/
protected $attempted_upload = array();
/**
* The default options that should be used if none supplied.
*
* @return array
*/
public static function default_options() {
return array(
'offloaded_files' => array(),
);
}
/**
* Prepare item for uploading by running filters, updating
*
* @param Item $as3cf_item
* @param array $options
*
* @return Manifest|WP_Error
*/
protected function pre_handle( Item $as3cf_item, array $options ) {
$manifest = new Manifest();
$source_type_name = $this->as3cf->get_source_type_name( $as3cf_item->source_type() );
$primary_key = Item::primary_object_key();
// Check for valid file path before attempting upload
if ( empty( $as3cf_item->source_path() ) ) {
$error_msg = sprintf( __( '%1$s with id %2$d does not have a valid file path', 'amazon-s3-and-cloudfront' ), $source_type_name, $as3cf_item->source_id() );
return $this->return_handler_error( $error_msg );
}
// Ensure path is a string
if ( ! is_string( $as3cf_item->source_path() ) ) {
$error_msg = sprintf( __( '%1$s with id %2$d. Provided path is not a string', 'amazon-s3-and-cloudfront' ), $source_type_name, $as3cf_item->source_id() );
return $this->return_handler_error( $error_msg );
}
// Ensure primary source file exists for new offload.
if ( empty( $as3cf_item->id() ) && ! file_exists( $as3cf_item->full_source_path( $primary_key ) ) ) {
$error_msg = sprintf( __( 'Primary file %1$s for %2$s with id %3$s does not exist', 'amazon-s3-and-cloudfront' ), $as3cf_item->full_source_path( $primary_key ), $source_type_name, $as3cf_item->source_id() );
return $this->return_handler_error( $error_msg );
}
// Get primary file's stats.
$file_name = wp_basename( $as3cf_item->source_path() );
$file_type = wp_check_filetype_and_ext( $as3cf_item->full_source_path(), $file_name );
$allowed_types = $this->as3cf->get_allowed_mime_types();
// check mime type of file is in allowed provider mime types
if ( ! in_array( $file_type['type'], $allowed_types, true ) ) {
$error_msg = sprintf( __( 'Mime type "%1$s" is not allowed (%2$s with id %3$s)', 'amazon-s3-and-cloudfront' ), $file_type['type'], $source_type_name, $as3cf_item->source_id() );
return $this->return_handler_error( $error_msg );
}
$default_acl = $this->as3cf->get_storage_provider()->get_default_acl();
$private_acl = $this->as3cf->get_storage_provider()->get_private_acl();
foreach ( $as3cf_item->objects() as $object_key => $object ) {
// Avoid attempting uploading to an item that doesn't have the primary file in place.
if ( $primary_key !== $object_key && empty( $as3cf_item->id() ) && ! isset( $manifest->objects[ $primary_key ] ) ) {
continue;
}
$source_path = $as3cf_item->full_source_path( $object_key );
// If the file has already been offloaded,
// don't try and (fail to) re-offload if the file isn't available.
if ( $this->in_offloaded_files( $object['source_file'], $options ) && ! file_exists( $source_path ) ) {
continue;
}
/**
* This filter allows you to change the public/private status of an individual file associated
* with an uploaded item before it's uploaded to the provider.
*
* @param bool $is_private Should the object be private?
* @param string $object_key A unique file identifier for a composite item, e.g. image's "size" such as full, small, medium, large.
* @param Item $as3cf_item The item being uploaded.
*
* @return bool
*/
$is_private = apply_filters( 'as3cf_upload_object_key_as_private', $as3cf_item->is_private( $object_key ), $object_key, $as3cf_item );
$as3cf_item->set_is_private( $is_private, $object_key );
$object_acl = $as3cf_item->is_private( $object_key ) ? $private_acl : $default_acl;
$args = array(
'Bucket' => $as3cf_item->bucket(),
'Key' => $as3cf_item->path( $object_key ),
'SourceFile' => $source_path,
'ContentType' => AS3CF_Utils::get_mime_type( $object['source_file'] ),
'CacheControl' => 'max-age=31536000',
);
// Only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
if ( ! empty( $object_acl ) && $this->as3cf->use_acl_for_intermediate_size( 0, $object_key, $as3cf_item->bucket(), $as3cf_item ) ) {
$args['ACL'] = $object_acl;
}
// TODO: Remove GZIP functionality.
// Handle gzip on supported items
if (
$this->should_gzip_file( $source_path, $as3cf_item->source_type() ) &&
false !== ( $gzip_body = gzencode( file_get_contents( $source_path ) ) )
) {
unset( $args['SourceFile'] );
$args['Body'] = $gzip_body;
$args['ContentEncoding'] = 'gzip';
}
$args = Storage_Provider::filter_object_meta( $args, $as3cf_item, $object_key );
// If the bucket is changed by the filter while processing the primary object,
// we should try and use that bucket for the item.
// If the bucket name is invalid, revert to configured bucket but log it.
// We don't abort here as ephemeral filesystems need to be accounted for,
// and the configured bucket is at least known to work.
if ( $primary_key === $object_key && $as3cf_item->bucket() !== $args['Bucket'] && empty( $as3cf_item->id() ) ) {
$bucket = $this->as3cf->check_bucket( $args['Bucket'] );
if ( $bucket ) {
$region = $this->as3cf->get_bucket_region( $bucket );
if ( is_wp_error( $region ) ) {
unset( $region );
}
}
if ( empty( $bucket ) || empty( $region ) ) {
$mesg = sprintf(
__( 'Bucket name "%1$s" is invalid, using "%2$s" instead.', 'amazon-s3-and-cloudfront' ),
$args['Bucket'],
$as3cf_item->bucket()
);
AS3CF_Error::log( $mesg );
$args['Bucket'] = $as3cf_item->bucket();
} else {
$args['Bucket'] = $bucket;
$as3cf_item->set_bucket( $bucket );
$as3cf_item->set_region( $region );
}
unset( $bucket, $region );
} elseif ( $primary_key === $object_key && $as3cf_item->bucket() !== $args['Bucket'] && ! empty( $as3cf_item->id() ) ) {
$args['Bucket'] = $as3cf_item->bucket();
AS3CF_Error::log( __( 'The bucket may not be changed via filters for a previously offloaded item.', 'amazon-s3-and-cloudfront' ) );
} elseif ( $primary_key !== $object_key && $as3cf_item->bucket() !== $args['Bucket'] ) {
$args['Bucket'] = $as3cf_item->bucket();
}
// If the Key has been changed for the primary object key, then that should be reflected in the item.
if ( $primary_key === $object_key && $as3cf_item->path( $object_key ) !== $args['Key'] && empty( $as3cf_item->id() ) ) {
$prefix = AS3CF_Utils::trailingslash_prefix( dirname( $args['Key'] ) );
if ( $prefix === '.' ) {
$prefix = '';
}
$as3cf_item->update_path_prefix( $prefix );
// If the filter tried to use a different filename too, log it.
if ( wp_basename( $args['Key'] ) !== wp_basename( $as3cf_item->path( $object_key ) ) ) {
$mesg = sprintf(
__( 'The offloaded filename must not be changed, "%1$s" has been used instead of "%2$s".', 'amazon-s3-and-cloudfront' ),
wp_basename( $as3cf_item->path( $object_key ) ),
wp_basename( $args['Key'] )
);
AS3CF_Error::log( $mesg );
}
} elseif ( $primary_key === $object_key && $as3cf_item->path( $object_key ) !== $args['Key'] && ! empty( $as3cf_item->id() ) ) {
$args['Key'] = $as3cf_item->path( $object_key );
AS3CF_Error::log( __( 'The key may not be changed via filters for a previously offloaded item.', 'amazon-s3-and-cloudfront' ) );
} elseif ( $primary_key !== $object_key && $as3cf_item->path( $object_key ) !== $args['Key'] ) {
$args['Key'] = $as3cf_item->path( $object_key );
}
// If ACL has been set, does the object's is_private need updating?
$is_private = ! empty( $args['ACL'] ) && $private_acl === $args['ACL'] || $as3cf_item->is_private( $object_key );
$as3cf_item->set_is_private( $is_private, $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'] ) && ! $this->as3cf->use_acl_for_intermediate_size( 0, $object_key, $as3cf_item->bucket(), $as3cf_item ) ) {
unset( $args['ACL'] );
}
// Adjust the actual Key to add the private prefix before uploading.
if ( $as3cf_item->is_private( $object_key ) ) {
$args['Key'] = $as3cf_item->provider_key( $object_key );
}
// If we've already attempted to offload this source file, leave it out of the manifest.
if ( in_array( md5( serialize( $args ) ), $this->attempted_upload ) ) {
continue;
}
if ( $primary_key === $object_key ) {
/**
* Actions fires when an Item's primary file might be offloaded.
*
* This action gives notice that an Item is being processed for upload to a bucket,
* and the given arguments represent the primary file's potential offload location.
* However, if the current process is for picking up extra files associated with the item,
* the indicated primary file may not actually be offloaded if it does not exist
* on the server but has already been offloaded.
*
* @param Item $as3cf_item The Item whose files are being offloaded.
* @param array $args The arguments that could be used to offload the primary file.
*/
do_action( 'as3cf_pre_upload_object', $as3cf_item, $args );
}
$manifest->objects[ $object_key ]['args'] = $args;
}
return $manifest;
}
/**
* Upload item files to remote storage provider
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool|WP_Error
*/
protected function handle_item( Item $as3cf_item, Manifest $manifest, array $options ) {
$source_type_name = $this->as3cf->get_source_type_name( $as3cf_item->source_type() );
try {
$provider_client = $this->as3cf->get_provider_client( $as3cf_item->region() );
} catch ( Exception $e ) {
return $this->return_handler_error( $e->getMessage() );
}
foreach ( $manifest->objects as $object_key => &$object ) {
$args = $object['args'];
$object['upload_result'] = array(
'status' => null,
'message' => null,
);
if ( ! file_exists( $args['SourceFile'] ) ) {
$error_msg = sprintf( __( 'File %1$s does not exist (%2$s with id %3$s)', 'amazon-s3-and-cloudfront' ), $args['SourceFile'], $source_type_name, $as3cf_item->source_id() );
$object['upload_result']['status'] = self::STATUS_FAILED;
$object['upload_result']['message'] = $error_msg;
// If the missing source file is the primary file, abort the whole process.
if ( Item::primary_object_key() === $object_key ) {
return false;
}
continue;
}
$this->attempted_upload[] = md5( serialize( $args ) );
// Try to do the upload
try {
$provider_client->upload_object( $args );
$object['upload_result']['status'] = self::STATUS_OK;
} catch ( Exception $e ) {
$error_msg = sprintf( __( 'Error offloading %1$s to provider: %2$s (%3$s with id %4$s)', 'amazon-s3-and-cloudfront' ), $args['SourceFile'], $e->getMessage(), $source_type_name, $as3cf_item->source_id() );
$object['upload_result']['status'] = self::STATUS_FAILED;
$object['upload_result']['message'] = $error_msg;
}
}
return true;
}
/**
* Handle local housekeeping after uploads.
*
* @param Item $as3cf_item
* @param Manifest $manifest
* @param array $options
*
* @return bool|WP_Error
*/
protected function post_handle( Item $as3cf_item, Manifest $manifest, array $options ) {
$item_objects = $as3cf_item->objects();
$errors = new WP_Error();
$i = 1;
// Reconcile the Item's objects with their manifest status.
foreach ( $item_objects as $object_key => $object ) {
// If there was no attempt made to offload the file,
// then remove it from list of offloaded objects.
// However, if the source file has previously been offloaded,
// we should just skip any further processing of it
// as the associated objects are still offloaded.
if ( ! isset( $manifest->objects[ $object_key ]['upload_result']['status'] ) ) {
if ( empty( $options['offloaded_files'][ $object['source_file'] ] ) ) {
unset( $item_objects[ $object_key ] );
}
continue;
}
// If the upload didn't succeed, we need to remove the object/size from the item.
// However, if the source file has previously been offloaded, we should just log the error.
if ( $manifest->objects[ $object_key ]['upload_result']['status'] !== self::STATUS_OK ) {
if ( empty( $options['offloaded_files'][ $object['source_file'] ] ) ) {
unset( $item_objects[ $object_key ] );
}
$errors->add( 'upload-object-' . ( $i++ ), $manifest->objects[ $object_key ]['upload_result']['message'] );
}
}
// Set the potentially changed list of offloaded objects.
$as3cf_item->set_objects( $item_objects );
// Only save if we have the primary file uploaded.
if ( isset( $item_objects[ Item::primary_object_key() ] ) ) {
$as3cf_item->save();
}
/**
* Fires action after uploading finishes
*
* @param Item $as3cf_item The item that was just uploaded
*/
do_action( 'as3cf_post_upload_item', $as3cf_item );
if ( count( $errors->get_error_codes() ) ) {
return $errors;
}
return true;
}
/**
* Should gzip file
*
* @param string $file_path
* @param string $source_type
*
* @return bool
*/
protected function should_gzip_file( $file_path, $source_type ) {
$file_type = wp_check_filetype_and_ext( $file_path, $file_path );
$mimes = $this->get_mime_types_to_gzip( $source_type );
if ( in_array( $file_type, $mimes ) && is_readable( $file_path ) ) {
return true;
}
return false;
}
/**
* Get mime types to gzip
*
* @param string $source_type
*
* @return array
*/
protected function get_mime_types_to_gzip( $source_type ) {
/**
* Return array of mime types that needs to be gzipped before upload
*
* @param array $mime_types The array of mime types
* @param bool $media_library If the uploaded file is part of the media library
* @param string $source_type The source type of the uploaded item
*/
return apply_filters(
'as3cf_gzip_mime_types',
array(
'css' => 'text/css',
'eot' => 'application/vnd.ms-fontobject',
'html' => 'text/html',
'ico' => 'image/x-icon',
'js' => 'application/javascript',
'json' => 'application/json',
'otf' => 'application/x-font-opentype',
'rss' => 'application/rss+xml',
'svg' => 'image/svg+xml',
'ttf' => 'application/x-font-ttf',
'woff' => 'application/font-woff',
'woff2' => 'application/font-woff2',
'xml' => 'application/xml',
),
'media_library' === $source_type,
$source_type
);
}
/**
* Has the given file name already been offloaded?
*
* @param string $filename
* @param array $options
*
* @return bool
*/
private function in_offloaded_files( $filename, $options ) {
if ( empty( $options['offloaded_files'] ) ) {
return false;
}
return array_key_exists( $filename, $options['offloaded_files'] );
}
}