Files
WPS3Media/classes/pro/integrations/media-library-pro.php
Malin 3248cbb029 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
2026-03-03 12:30:18 +01:00

1532 lines
46 KiB
PHP

<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Integrations;
use Amazon_S3_And_CloudFront_Pro;
use AS3CF_Error;
use AS3CF_Utils;
use DeliciousBrains\WP_Offload_Media\Integrations\Media_Library;
use DeliciousBrains\WP_Offload_Media\Items\Download_Handler;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item;
use DeliciousBrains\WP_Offload_Media\Items\Remove_Local_Handler;
use DeliciousBrains\WP_Offload_Media\Items\Upload_Handler;
use DeliciousBrains\WP_Offload_Media\Pro\Items\Remove_Provider_Handler;
use DeliciousBrains\WP_Offload_Media\Pro\Items\Update_Acl_Handler;
use DeliciousBrains\WP_Offload_Media\Providers\Provider;
use Exception;
use WP_Error;
use WP_Post;
use WP_Query;
use WP_User;
class Media_Library_Pro extends Media_Library {
/**
* @var array
*/
protected $messages;
/**
* @var array
*/
protected $user_can_use_media_actions;
/**
* @var Amazon_S3_And_CloudFront_Pro
*/
protected $as3cf;
/**
* Keep track of media library columns managed by us.
*
* @var array
*/
private $managed_columns = array();
/**
* Available media actions for the bucket column.
*
* @var array
*/
private $bucket_col_actions = array();
/**
* Available media actions for the access column.
*
* @var array
*/
private $access_col_actions = array();
/**
* Config for filters rendered in list and grid views
*
* @var array
*/
private $media_library_filters = array();
/**
* @inheritDoc
*/
public function setup() {
parent::setup();
add_action( 'load-upload.php', array( $this, 'load_media_pro_assets' ), 11 );
add_action( 'as3cf_load_attachment_assets', array( $this, 'load_attachment_js' ) );
// Ajax handlers
add_action( 'wp_ajax_as3cfpro_process_media_action', array( $this, 'ajax_process_media_action' ) );
add_action( 'wp_ajax_as3cfpro_update_acl', array( $this, 'ajax_update_acl' ) );
// Pro customisations
add_filter( 'as3cf_media_action_strings', array( $this, 'media_action_strings' ) );
if ( ! is_multisite() ) {
add_filter( 'as3cf_get_summary_counts', array( $this, 'add_urls_to_summary_counts' ) );
}
// Media row actions
add_filter( 'wp_prepare_attachment_for_js', array( $this, 'enrich_attachment_model' ), 10, 2 );
add_filter( 'bulk_actions-upload', array( $this, 'add_list_table_bulk_actions' ) );
add_filter( 'media_row_actions', array( $this, 'add_media_row_actions' ), 10, 2 );
add_action( 'admin_notices', array( $this, 'maybe_display_media_action_message' ) );
add_action( 'admin_init', array( $this, 'process_media_actions' ) );
// Columns and filters
if ( is_admin() ) {
add_filter( 'manage_media_columns', array( $this, 'add_media_library_columns' ) );
add_filter( 'manage_upload_sortable_columns', array( $this, 'add_media_library_sortable_columns' ) );
add_filter( 'manage_media_custom_column', array( $this, 'get_media_library_column_content' ), 10, 2 );
add_action( 'restrict_manage_posts', array( $this, 'add_media_library_filters' ) );
add_action( 'pre_get_posts', array( $this, 'media_library_filter_query' ) );
$this->managed_columns = array(
'as3cf_bucket' => _x( 'Bucket', 'Media Library column heading', 'amazon-s3-and-cloudfront' ),
'as3cf_access' => _x( 'Access', 'Media Library column heading', 'amazon-s3-and-cloudfront' ),
);
}
// Update media counts cache with current provider and bucket usage.
add_filter( 'as3cf_media_counts_for_blog', array( $this, 'add_providers_and_buckets_to_media_counts' ), 10, 3 );
}
/**
* Load the media Pro assets
*/
public function load_media_pro_assets() {
if ( ! $this->as3cf->is_plugin_setup() ) {
return;
}
$this->as3cf->enqueue_script( 'as3cf-pro-media-script', 'assets/js/pro/media', array(
'jquery',
'media-views',
'media-grid',
'wp-util',
), false );
$nonces = array(
'get_attachment_provider_details' => wp_create_nonce( 'get-attachment-s3-details' ),
);
foreach ( $this->get_available_media_actions() as $action => $scopes ) {
foreach ( $scopes as $scope ) {
$nonces[ $scope . '_' . $action ] = wp_create_nonce( "$scope-$action" );
}
}
wp_localize_script( 'as3cf-pro-media-script', 'as3cfpro_media', array(
'strings' => $this->get_media_action_strings(),
'actions' => array(
'bulk' => $this->get_available_media_actions( 'bulk' ),
'singular' => $this->get_available_media_actions( 'singular' ),
),
'nonces' => $nonces,
'settings' => array(
'default_acl' => $this->as3cf->get_storage_provider()->get_default_acl(),
'private_acl' => $this->as3cf->get_storage_provider()->get_private_acl(),
),
'filters' => $this->get_media_library_filters_config(),
) );
}
/**
* Load the attachment JS only when editing an attachment.
*/
public function load_attachment_js() {
$this->as3cf->enqueue_script( 'as3cf-pro-attachment-script', 'assets/js/pro/attachment', array(
'jquery',
'wp-util',
), false );
$actions = $this->get_available_media_actions( 'singular' );
$nonces = array();
foreach ( $actions as $action ) {
$nonces[ 'singular_' . $action ] = wp_create_nonce( "singular-$action" );
}
wp_localize_script( 'as3cf-pro-attachment-script', 'as3cfpro_media', array(
'strings' => array(
'local_warning' => $this->get_media_action_strings( 'local_warning' ),
'updating_acl' => $this->get_media_action_strings( 'updating_acl' ),
'change_acl_error' => $this->get_media_action_strings( 'change_acl_error' ),
),
'actions' => $actions,
'nonces' => $nonces,
'settings' => array(
'post_id' => get_the_ID(),
'default_acl' => $this->as3cf->get_storage_provider()->get_default_acl(),
'private_acl' => $this->as3cf->get_storage_provider()->get_private_acl(),
),
) );
}
/**
* Handle updating the ACL for an attachment
*/
public function ajax_update_acl() {
check_ajax_referer( 'singular-update_acl' );
$id = $this->as3cf->filter_input( 'id', INPUT_POST, FILTER_VALIDATE_INT ); // input var ok
$acl = sanitize_text_field( $_REQUEST['acl'] ); // input var ok
$title = $this->get_media_action_strings( 'change_to_public' );
$is_private = true;
if ( empty( $id ) || empty( $acl ) ) {
wp_send_json_error();
}
if ( $this->as3cf->get_storage_provider()->get_private_acl() !== $acl ) {
$acl = $this->as3cf->get_storage_provider()->get_default_acl();
$title = $this->get_media_action_strings( 'change_to_private' );
$is_private = false;
}
// Update on provider.
$as3cf_item = Media_Library_Item::get_by_source_id( $id );
/** @var Update_Acl_Handler $updace_acl_handler */
$update_acl_handler = $this->as3cf->get_item_handler( Update_Acl_Handler::get_item_handler_key_name() );
$update = $update_acl_handler->handle( $as3cf_item, array( 'set_private' => $is_private ) );
$data = array(
'acl' => $acl,
'acl_display' => $this->as3cf->get_acl_display_name( $acl ),
'title' => $title,
'url' => wp_get_attachment_url( $id ),
);
if ( is_wp_error( $update ) ) {
wp_send_json_error();
}
wp_send_json_success( $data );
}
/**
* Enrich the attachment model attributes used in JS
*
* @param array $response Array of prepared attachment data.
* @param int|object $attachment Attachment ID or object.
*
* @return array
*/
public function enrich_attachment_model( $response, $attachment ) {
$file = get_attached_file( $attachment->ID, true );
// flag if the attachment file doesn't exist locally
// so we can ask for confirmation when removing from S3
$response['bulk_local_warning'] = ! file_exists( $file );
return $response;
}
/**
* Add bulk media actions to a list table's bulk actions dropdown.
*
* @param array $actions
*
* @return array
*/
public function add_list_table_bulk_actions( $actions ) {
$strings = $this->get_media_action_strings();
foreach ( $this->get_available_media_actions( 'bulk' ) as $action ) {
$actions[ 'bulk_as3cfpro_' . $action ] = $strings[ $action ];
}
return $actions;
}
/**
* Add Pro media action strings.
*
* @param array $strings
*
* @return array
*/
public function media_action_strings( $strings ) {
$strings['copy'] = __( 'Copy to Bucket', 'amazon-s3-and-cloudfront' );
$strings['remove'] = __( 'Remove from Bucket', 'amazon-s3-and-cloudfront' );
$strings['remove_local'] = __( 'Remove from Server', 'amazon-s3-and-cloudfront' );
$strings['download'] = __( 'Copy from Bucket to Server', 'amazon-s3-and-cloudfront' );
$strings['private_acl'] = __( 'Make Private in Bucket', 'amazon-s3-and-cloudfront' );
$strings['public_acl'] = __( 'Make Public in Bucket', 'amazon-s3-and-cloudfront' );
$strings['local_warning'] = __( 'This file does not exist locally so removing it from the bucket will result in broken links on your site. Are you sure you want to continue?', 'amazon-s3-and-cloudfront' );
$strings['bulk_local_warning'] = __( 'Some files do not exist locally so removing them from the bucket will result in broken links on your site. Are you sure you want to continue?', 'amazon-s3-and-cloudfront' );
$strings['change_to_private'] = __( 'Click to set as Private in the bucket', 'amazon-s3-and-cloudfront' );
$strings['change_to_public'] = __( 'Click to set as Public in the bucket', 'amazon-s3-and-cloudfront' );
$strings['updating_acl'] = __( 'Updating…', 'amazon-s3-and-cloudfront' );
$strings['change_acl_error'] = __( 'There was an error changing the ACL. Make sure the IAM user has permission to change the ACL and try again.', 'amazon-s3-and-cloudfront' );
return $strings;
}
/**
* Conditionally adds media action links for an attachment on the Media library list view.
*
* @param array $actions
* @param WP_Post|int $post
*
* @return array
*/
public function add_media_row_actions( array $actions, $post ) {
$available_actions = $this->get_available_media_actions( 'singular' );
$this->bucket_col_actions = array();
$this->access_col_actions = array();
if ( ! $available_actions ) {
return $actions;
}
$post_id = ( is_object( $post ) ) ? $post->ID : $post;
$file = get_attached_file( $post_id, true );
$file_exists = file_exists( $file );
$as3cf_item = Media_Library_Item::get_by_source_id( $post_id );
// If offloaded to another provider can not do anything.
if ( $as3cf_item && ! $as3cf_item->served_by_provider( true ) ) {
$this->bucket_col_actions['as3cfpro_wrong_provider'] = sprintf(
'<span title="%s" class="%s">%s</span>',
__( 'Offloaded to a different provider than currently configured.', 'amazon-s3-and-cloudfront' ),
'as3cf-warning',
__( 'Disconnected', 'amazon-s3-and-cloudfront' )
);
return $actions;
}
// If not offloaded at all, or offloaded to current provider, can use copy.
if ( in_array( 'copy', $available_actions ) && $file_exists && ( ! $as3cf_item || $as3cf_item->served_by_provider( true ) ) ) {
$this->add_media_row_action( $actions, $post_id, 'copy' );
}
// Actions beyond this point are for items on provider only
if ( ! $as3cf_item || ! $as3cf_item->served_by_provider( true ) ) {
return $actions;
}
if ( in_array( 'remove', $available_actions ) ) {
if ( 'grid' === $this->render_context ) {
$this->add_media_row_action( $actions, $post_id, 'remove' );
} else {
$this->add_media_row_action( $this->bucket_col_actions, $post_id, 'remove' );
}
}
if ( in_array( 'download', $available_actions ) && ! $file_exists ) {
$this->add_media_row_action( $actions, $post_id, 'download' );
}
if ( in_array( 'private_acl', $available_actions ) && ! $as3cf_item->is_private() ) {
if ( 'grid' === $this->render_context ) {
$this->add_media_row_action( $actions, $post_id, 'private_acl' );
} else {
$this->add_media_row_action( $this->access_col_actions, $post_id, 'private_acl', __( 'Make Private', 'amazon-s3-and-cloudfront' ) );
}
}
if ( in_array( 'public_acl', $available_actions ) && $as3cf_item->is_private() ) {
if ( 'grid' === $this->render_context ) {
$this->add_media_row_action( $actions, $post_id, 'public_acl' );
} else {
$this->add_media_row_action( $this->access_col_actions, $post_id, 'public_acl', __( 'Make Public', 'amazon-s3-and-cloudfront' ) );
}
}
if ( in_array( 'remove_local', $available_actions ) && $file_exists ) {
$this->add_media_row_action( $actions, $post_id, 'remove_local' );
}
return $actions;
}
/**
* Display notices after processing media actions
*/
public function maybe_display_media_action_message() {
global $pagenow;
if ( ! in_array( $pagenow, array( 'upload.php', 'post.php' ) ) ) {
return;
}
if ( isset( $_GET['as3cfpro-action'] ) && isset( $_GET['errors'] ) && isset( $_GET['count'] ) ) {
$action = sanitize_key( $_GET['as3cfpro-action'] ); // input var okay
$error_count = absint( $_GET['errors'] ); // input var okay
$count = absint( $_GET['count'] ); // input var okay
$message_html = $this->get_media_action_result_message( $action, $count, $error_count );
if ( false !== $message_html ) {
echo $message_html;
}
}
}
/**
* Handler for single and bulk media actions
*/
public function process_media_actions() {
if ( AS3CF_Utils::is_ajax() ) {
return;
}
global $pagenow;
if ( 'upload.php' != $pagenow ) {
return;
}
if ( ! isset( $_GET['action'] ) ) { // input var okay
return;
}
if ( ! empty( $_REQUEST['action2'] ) && '-1' != $_REQUEST['action2'] ) {
// Handle bulk actions from the footer bulk action select
$action = sanitize_key( $_REQUEST['action2'] ); // input var okay
} else {
$action = sanitize_key( $_REQUEST['action'] ); // input var okay
}
if ( false === strpos( $action, 'bulk_as3cfpro_' ) ) {
$available_actions = $this->get_available_media_actions( 'singular' );
$referrer = 'as3cfpro-' . $action;
$doing_bulk_action = false;
if ( ! isset( $_GET['ids'] ) ) {
return;
}
$ids = explode( ',', $_GET['ids'] ); // input var okay
} else {
$available_actions = $this->get_available_media_actions( 'bulk' );
$action = str_replace( 'bulk_as3cfpro_', '', $action );
$referrer = 'bulk-media';
$doing_bulk_action = true;
if ( ! isset( $_REQUEST['media'] ) ) {
return;
}
$ids = $_REQUEST['media']; // input var okay
}
if ( ! in_array( $action, $available_actions ) ) {
return;
}
$ids = array_map( 'intval', $ids );
$id_count = count( $ids );
check_admin_referer( $referrer );
$sendback = isset( $_GET['sendback'] ) ? $_GET['sendback'] : wp_get_referer();
$args = array(
'as3cfpro-action' => $action,
);
$result = $this->maybe_do_provider_action( $action, $ids, $doing_bulk_action );
if ( ! $result ) {
unset( $args['as3cfpro-action'] );
$result = array();
}
// If we're uploading a single file, add the id to the `$args` array.
if ( 'copy' === $action && 1 === $id_count && ! empty( $result ) && 1 === ( $result['count'] + $result['errors'] ) ) {
$args['as3cf_id'] = array_shift( $ids );
}
$args = array_merge( $args, $result );
$url = add_query_arg( $args, $sendback );
wp_redirect( esc_url_raw( $url ) );
$this->_exit();
}
/**
* Add an action link to the media actions array
*
* @param array $actions
* @param int $post_id
* @param string $action
* @param string $text
* @param bool $show_warning
*/
public function add_media_row_action( &$actions, $post_id, $action, $text = '', $show_warning = false ) {
$url = $this->get_media_action_url( $action, $post_id );
$text = $text ? $text : $this->get_media_action_strings( $action );
$class = $action;
if ( $show_warning ) {
$class .= ' local-warning';
}
$actions[ 'as3cfpro_' . $action ] = '<a href="' . $url . '" class="' . $class . '" title="' . esc_attr( $text ) . '">' . esc_html( $text ) . '</a>';
}
/**
* Check we can do the media actions
*
* @return bool
*/
public function verify_media_actions() {
if ( ! $this->as3cf->is_pro_plugin_setup( true ) ) {
return false;
}
return $this->user_can_use_media_actions();
}
/**
* Get a list of available media actions which can be performed according to plugin and user capability requirements.
*
* @param string|null $scope
*
* @return array
*/
public function get_available_media_actions( $scope = null ) {
$actions = array();
if ( ! $this->as3cf->is_plugin_setup( true ) || ! $this->user_can_use_media_actions() ) {
return $actions;
}
// We've already tested provider credentials, but is license ok?
if ( $this->as3cf->is_pro_plugin_setup( true ) ) {
$actions['copy'] = array( 'singular', 'bulk' );
$actions['download'] = array( 'singular', 'bulk' );
$actions['update_acl'] = array( 'singular' );
$actions['private_acl'] = array( 'singular', 'bulk' );
$actions['public_acl'] = array( 'singular', 'bulk' );
$actions['remove_local'] = array( 'singular', 'bulk' );
}
// Remove from Bucket should still be available even if license exceeded/invalid.
$actions['remove'] = array( 'singular', 'bulk' );
if ( $scope ) {
$in_scope = array_filter( $actions, function ( $scopes ) use ( $scope ) {
return in_array( $scope, $scopes );
} );
return array_keys( $in_scope );
}
return $actions;
}
/**
* Check if the given user can use on-demand S3 media actions.
*
* @param null|int|WP_User $user User to check. Defaults to current user.
*
* @return bool
*/
public function user_can_use_media_actions( $user = null ) {
$user = $user ? $user : wp_get_current_user();
if ( is_object( $user ) ) {
$user = $user->ID;
}
if ( ! is_null( $this->user_can_use_media_actions ) && isset( $this->user_can_use_media_actions[ $user ] ) ) {
return $this->user_can_use_media_actions[ $user ];
}
$this->user_can_use_media_actions[ $user ] = false;
if ( user_can( $user, 'use_as3cf_media_actions' ) ) {
$this->user_can_use_media_actions[ $user ] = true;
} else {
/**
* The default capability for using on-demand S3 media actions.
*
* @param string $capability Registered capability identifier
*/
$capability = apply_filters( 'as3cfpro_media_actions_capability', 'manage_options' );
$this->user_can_use_media_actions[ $user ] = user_can( $user, $capability );
}
return $this->user_can_use_media_actions[ $user ];
}
/**
* Generate the URL for performing S3 media actions
*
* @param string $action
* @param int $post_id
* @param null|string $sendback_path
*
* @return string
*/
public function get_media_action_url( $action, $post_id, $sendback_path = null ) {
$args = array(
'action' => $action,
'ids' => $post_id,
);
if ( ! is_null( $sendback_path ) ) {
$args['sendback'] = urlencode( admin_url( $sendback_path ) );
}
$url = add_query_arg( $args, admin_url( 'upload.php' ) );
$url = wp_nonce_url( $url, 'as3cfpro-' . $action );
return esc_url( $url );
}
/**
* Handle S3 actions applied to attachments via the Backbone JS
* in the media grid and edit attachment modal
*/
public function ajax_process_media_action() {
if ( ! isset( $_POST['s3_action'] ) && ! isset( $_POST['ids'] ) ) {
return;
}
$scope = filter_input( INPUT_POST, 'scope' );
$action = filter_input( INPUT_POST, 's3_action' );
check_ajax_referer( "{$scope}-{$action}" );
$ids = array_map( 'intval', $_POST['ids'] ); // input var okay
// process the S3 action for the attachments
$return = $this->maybe_do_provider_action( $action, $ids, true );
$message_html = '';
if ( $return ) {
$message_html = $this->get_media_action_result_message( $action, $return['count'], $return['errors'] );
}
wp_send_json_success( $message_html );
}
/**
* Wrapper for media actions
*
* @param string $action type of media action, copy, remove, download, remove_local
* @param array $ids attachment IDs
* @param bool $doing_bulk_action flag for multiple attachments, if true then we need to
* perform a check for each attachment
*
* @return bool|array on success array with success count and error count
* @throws Exception
*/
public function maybe_do_provider_action( $action, $ids, $doing_bulk_action ) {
switch ( $action ) {
case 'copy':
$result = $this->maybe_upload_attachments( $ids, $doing_bulk_action );
break;
case 'remove':
$result = $this->maybe_delete_attachments_from_provider( $ids, $doing_bulk_action );
break;
case 'download':
$result = $this->maybe_download_attachments_from_provider( $ids, $doing_bulk_action );
break;
case 'private_acl':
$result = $this->maybe_update_acls_to_private( $ids, $doing_bulk_action );
break;
case 'public_acl':
$result = $this->maybe_update_acls_to_public( $ids, $doing_bulk_action );
break;
case 'remove_local':
$result = $this->maybe_remove_local_files_for_attachments( $ids, $doing_bulk_action );
break;
default:
// not one of our actions, remove
$result = false;
break;
}
return $result;
}
/**
* Get the result message after an S3 action has been performed
*
* @param string $action type of S3 action
* @param int $count count of successful processes
* @param int $error_count count of errors
*
* @return bool|string
*/
public function get_media_action_result_message( $action, $count = 0, $error_count = 0 ) {
$class = 'updated';
$type = 'success';
if ( 0 === $count && 0 === $error_count ) {
// don't show any message if no attachments processed
// i.e. they haven't met the checks for bulk actions
return false;
}
if ( $error_count > 0 ) {
$type = 'error';
$class = $type;
// We have processed some successfully.
if ( $count > 0 ) {
$type = 'partial';
}
}
$message = $this->get_message( $action, $type );
// can't find a relevant message, abort
if ( ! $message ) {
return false;
}
$id = $this->as3cf->filter_input( 'as3cf_id', INPUT_GET, FILTER_VALIDATE_INT );
// If we're uploading a single item, add an edit link.
if ( 1 === ( $count + $error_count ) && ! empty( $id ) ) {
$url = esc_url( get_edit_post_link( $id ) );
// Only add the link if we have a URL.
if ( ! empty( $url ) ) {
$text = esc_html__( 'Edit attachment', 'amazon-s3-and-cloudfront' );
$message .= sprintf( ' <a href="%1$s">%2$s</a>', $url, $text );
}
}
$message = sprintf( '<div class="notice as3cf-notice %s is-dismissible"><p>%s</p></div>', $class, $message );
return $message;
}
/**
* Retrieve all the media action related notice messages
*
* @return array
*/
public function get_messages() {
if ( is_null( $this->messages ) ) {
$this->messages = array(
'copy' => array(
'success' => __( 'Media successfully copied to bucket.', 'amazon-s3-and-cloudfront' ),
'partial' => __( 'Media copied to bucket with some errors.', 'amazon-s3-and-cloudfront' ),
'error' => __( 'There were errors when copying the media to bucket.', 'amazon-s3-and-cloudfront' ),
),
'remove' => array(
'success' => __( 'Media successfully removed from bucket.', 'amazon-s3-and-cloudfront' ),
'partial' => __( 'Media removed from bucket, with some errors.', 'amazon-s3-and-cloudfront' ),
'error' => __( 'There were errors when removing the media from bucket.', 'amazon-s3-and-cloudfront' ),
),
'download' => array(
'success' => __( 'Media successfully downloaded from bucket.', 'amazon-s3-and-cloudfront' ),
'partial' => __( 'Media downloaded from bucket, with some errors.', 'amazon-s3-and-cloudfront' ),
'error' => __( 'There were errors when downloading the media from bucket.', 'amazon-s3-and-cloudfront' ),
),
'private_acl' => array(
'success' => __( 'Media successfully set as private in bucket.', 'amazon-s3-and-cloudfront' ),
'partial' => __( 'Media set as private in bucket, with some errors.', 'amazon-s3-and-cloudfront' ),
'error' => __( 'There were errors when setting the media as private in bucket.', 'amazon-s3-and-cloudfront' ),
),
'public_acl' => array(
'success' => __( 'Media successfully set as public in bucket.', 'amazon-s3-and-cloudfront' ),
'partial' => __( 'Media set as public in bucket, with some errors.', 'amazon-s3-and-cloudfront' ),
'error' => __( 'There were errors when setting the media as public in bucket.', 'amazon-s3-and-cloudfront' ),
),
'remove_local' => array(
'success' => __( 'Media successfully removed from server.', 'amazon-s3-and-cloudfront' ),
'partial' => __( 'Media removed from server, with some errors.', 'amazon-s3-and-cloudfront' ),
'error' => __( 'There were errors when removing the media from server.', 'amazon-s3-and-cloudfront' ),
),
);
}
return $this->messages;
}
/**
* Get a specific media action notice message
*
* @param string $action type of action, e.g. copy, remove, download
* @param string $type if the action has resulted in success, error, partial (errors)
*
* @return string|bool
*/
public function get_message( $action = 'copy', $type = 'success' ) {
$messages = $this->get_messages();
if ( isset( $messages[ $action ][ $type ] ) ) {
return $messages[ $action ][ $type ];
}
return false;
}
/**
* Wrapper for uploading multiple attachments to S3
*
* @param array $post_ids attachment IDs
* @param bool $doing_bulk_action flag for multiple attachments, if true then we need to
* perform a check for each attachment to make sure the
* file exists locally before uploading to S3
*
* @return array|WP_Error
* @throws Exception
*/
public function maybe_upload_attachments( $post_ids, $doing_bulk_action = false ) {
$result = array(
'errors' => 0,
'count' => 0,
);
$upload_handler = $this->as3cf->get_item_handler( Upload_Handler::get_item_handler_key_name() );
foreach ( $post_ids as $post_id ) {
if ( $doing_bulk_action ) {
// If bulk action check the file exists.
$file = get_attached_file( $post_id, true );
// If the file doesn't exist locally we can't copy.
if ( ! file_exists( $file ) ) {
continue;
}
}
$offloaded_files = array();
$as3cf_item = Media_Library_Item::get_by_source_id( $post_id );
if ( empty( $as3cf_item ) ) {
$as3cf_item = Media_Library_Item::create_from_source_id( $post_id );
} else {
// Refresh the list of expected objects from attachment's expected thumbnail sizes.
$offloaded_files = $as3cf_item->offloaded_files();
$metadata = wp_get_attachment_metadata( $post_id, true );
if ( empty( $metadata ) || is_wp_error( $metadata ) ) {
$result['errors']++;
continue;
}
$this->update_item_from_new_metadata( $as3cf_item, $metadata );
}
if ( ! empty( $as3cf_item ) && ! is_wp_error( $as3cf_item ) ) {
$upload_result = $upload_handler->handle( $as3cf_item, $offloaded_files );
// Even if an upload was cancelled via a filter,
// if there was no error, then it was successfully processed.
if ( is_wp_error( $upload_result ) ) {
AS3CF_Error::log( $upload_result->get_error_messages() );
} else {
$result['count']++;
continue;
}
}
$result['errors']++;
}
return $result;
}
/**
* Wrapper for removing multiple attachments from provider.
*
* @param array $post_ids Attachment IDs
* @param bool $doing_bulk_action Flag for multiple attachments. If true then we need to
* perform a check for each attachment to make sure it has
* been uploaded to provider before trying to delete it.
* This flag currently has no effect, but is retained for
* consistency with similar functions in this class.
*
* @return array
* @throws Exception
*/
public function maybe_delete_attachments_from_provider( $post_ids, $doing_bulk_action = false ) {
$result = array(
'errors' => 0,
'count' => 0,
);
/** @var Remove_Provider_Handler $remove_handler */
$remove_handler = $this->as3cf->get_item_handler( Remove_Provider_Handler::get_item_handler_key_name() );
foreach ( $post_ids as $post_id ) {
$as3cf_item = Media_Library_Item::get_by_source_id( $post_id );
if ( empty( $as3cf_item ) ) {
$result['count']++;
continue;
}
// Delete attachment from provider.
$remove_result = $remove_handler->handle( $as3cf_item );
if ( is_wp_error( $remove_result ) ) {
$result['errors']++;
AS3CF_Error::log( $remove_result->get_error_messages() );
continue;
}
// There was no error, so either objects deleted or skipped due to duplicates.
$as3cf_item->delete();
if ( Media_Library_Item::get_by_source_id( $post_id ) ) {
$result['errors']++;
continue;
}
$result['count']++;
}
return $result;
}
/**
* Wrapper for downloading multiple attachments from provider.
*
* @param array $post_ids Attachment IDs
* @param bool $doing_bulk_action Flag for multiple attachments. If true then we need to
* perform a check for each attachment to make sure it has
* been uploaded to provider and does not exist locally before
* trying to download it.
*
* @return array
* @throws Exception
*/
public function maybe_download_attachments_from_provider( $post_ids, $doing_bulk_action = false ) {
$result = array(
'errors' => 0,
'count' => 0,
);
$download_handler = $this->as3cf->get_item_handler( Download_Handler::get_item_handler_key_name() );
foreach ( $post_ids as $post_id ) {
$file = get_attached_file( $post_id, true );
$as3cf_item = Media_Library_Item::get_by_source_id( $post_id );
if ( empty( $as3cf_item ) || is_wp_error( $as3cf_item ) ) {
continue;
}
// If bulk action, check whether original file exists locally before trying to download.
$file_exists_locally = $doing_bulk_action && file_exists( $file );
if ( ! $file_exists_locally ) {
// Download the attachment from provider.
$download_handler->handle( $as3cf_item );
if ( ! file_exists( $file ) ) {
$result['errors']++;
continue;
}
}
$result['count']++;
}
return $result;
}
/**
* Wrapper for removing multiple attachments from server if offloaded
*
* @param array $post_ids attachment IDs
* @param bool $doing_bulk_action flag for multiple attachments, if true then we need to
* perform a check for each attachment to make sure it has
* been uploaded to bucket before trying to delete it
*
* @return array
* @throws Exception
*/
public function maybe_remove_local_files_for_attachments( $post_ids, $doing_bulk_action = false ) {
$result = array(
'errors' => 0,
'count' => 0,
);
$as3cf_items = array();
foreach ( $post_ids as $key => $post_id ) {
$as3cf_item = Media_Library_Item::get_by_source_id( $post_id );
if ( empty( $as3cf_item ) ) {
$result['errors']++;
unset( $post_ids[ $key ] );
continue;
}
$as3cf_items[ $post_id ] = $as3cf_item;
}
if ( ! empty( $post_ids ) ) {
/** @var Remove_Local_Handler $remove_local_handler */
$remove_local_handler = $this->as3cf->get_item_handler( Remove_Local_Handler::get_item_handler_key_name() );
// Set up to check provider keys before removing.
$options = array(
'verify_exists_on_provider' => true,
'provider_keys' => $this->as3cf->get_provider_keys( $post_ids ),
);
foreach ( $post_ids as $post_id ) {
$as3cf_item = $as3cf_items[ $post_id ];
$remove_local_handler->handle( $as3cf_item, $options );
if ( ! $this->attachment_exists_locally( $post_id ) ) {
$result['count']++;
} else {
$result['errors']++;
}
}
}
return $result;
}
/**
* Wrapper for updating the ACLs for multiple attachments on provider
*
* @param array $post_ids Attachment IDs
* @param bool $doing_bulk_action Flag for multiple attachments, if true then we need to
* perform a check for each attachment to make sure it has
* been uploaded to the current provider
* @param bool $private Setting to private ACL? Default is public.
*
* @return array|WP_Error
* @throws Exception
*/
private function maybe_update_acls( $post_ids, $doing_bulk_action = false, $private = false ) {
$result = array(
'errors' => 0,
'count' => 0,
);
$provider_key = $this->as3cf->get_storage_provider()->get_provider_key_name();
/** @var Update_Acl_Handler $update_acl_handler */
$update_acl_handler = $this->as3cf->get_item_handler( Update_Acl_Handler::get_item_handler_key_name() );
$options = array( 'set_private' => $private );
foreach ( $post_ids as $post_id ) {
$as3cf_item = Media_Library_Item::get_by_source_id( $post_id );
if ( ! $as3cf_item ) {
$result['errors']++;
continue;
}
if ( $doing_bulk_action ) {
if ( $provider_key !== $as3cf_item->provider() ) {
$result['errors']++;
continue;
}
}
$update_result = $update_acl_handler->handle( $as3cf_item, $options );
if ( is_wp_error( $update_result ) ) {
$result['errors']++;
AS3CF_Error::log( $update_result->get_error_messages() );
continue;
}
$result['count']++;
}
return $result;
}
/**
* Wrapper for updating the ACLs to private for multiple attachments on provider
*
* @param array $post_ids Attachment IDs
* @param bool $doing_bulk_action Flag for multiple attachments, if true then we need to
* perform a check for each attachment to make sure it has
* been uploaded to the current provider
*
* @return array|WP_Error
* @throws Exception
*/
private function maybe_update_acls_to_private( $post_ids, $doing_bulk_action = false ) {
return $this->maybe_update_acls( $post_ids, $doing_bulk_action, true );
}
/**
* Wrapper for updating the ACLs to public for multiple attachments on provider
*
* @param array $post_ids Attachment IDs
* @param bool $doing_bulk_action Flag for multiple attachments, if true then we need to
* perform a check for each attachment to make sure it has
* been uploaded to the current provider
*
* @return array|WP_Error
* @throws Exception
*/
private function maybe_update_acls_to_public( $post_ids, $doing_bulk_action = false ) {
return $this->maybe_update_acls( $post_ids, $doing_bulk_action, false );
}
/**
* Get attachment local paths.
*
* @param int $attachment_id
*
* @return array
*/
private function get_attachment_local_paths( $attachment_id ) {
static $local_paths = array();
$blog_id = get_current_blog_id();
if ( isset( $local_paths[ $blog_id ][ $attachment_id ] ) ) {
return $local_paths[ $blog_id ][ $attachment_id ];
}
$paths = AS3CF_Utils::get_attachment_file_paths( $attachment_id, false );
$local_paths[ $blog_id ][ $attachment_id ] = array_unique( $paths );
return $paths;
}
/**
* Does attachment exist locally?
*
* @param int $attachment_id
*
* @return bool
*/
private function attachment_exists_locally( $attachment_id ) {
$paths = $this->get_attachment_local_paths( $attachment_id );
foreach ( $paths as $path ) {
if ( file_exists( $path ) ) {
return true;
}
}
return false;
}
/**
* Get ACL value string.
*
* @param array $acl
* @param int $post_id
*
* @return string
*/
public function get_acl_value_string( $acl, $post_id ) {
$as3cf_item = Media_Library_Item::get_by_source_id( $post_id );
if ( ! in_array( 'update_acl', $this->get_available_media_actions( 'singular' ) ) || ! $this->as3cf || ! $as3cf_item->served_by_provider( true ) ) {
return parent::get_acl_value_string( $acl, $post_id );
}
return sprintf( '<a id="as3cfpro-toggle-acl" title="%s" data-currentACL="%s" href="#">%s</a>', $acl['title'], $acl['acl'], $acl['name'] );
}
/**
* Helper function for terminating script execution. Easily testable.
*
* @param int|string $exit_code
*
* @return void
*
* phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
*/
public function _exit( $exit_code = 0 ) {
exit( $exit_code );
}
/**
* Add column with as3cf item details to the media library list view.
*
* @handles manage_media_columns
*
* @param array $columns
*
* @return array
*/
public function add_media_library_columns( array $columns ): array {
return array_merge( $columns, $this->managed_columns );
}
/**
* Add our columns as sortable.
*
* @handles manage_upload_sortable_columns
*
* @param array $columns
*
* @return array
*/
public function add_media_library_sortable_columns( array $columns ): array {
foreach ( $this->managed_columns as $key => $caption ) {
$columns[ $key ] = $key;
}
return $columns;
}
/**
* Render column content for individual media library item.
*
* @handles manage_media_custom_column
*
* @param string $column_name
* @param string $post_id
*/
public function get_media_library_column_content( string $column_name, string $post_id ) {
if ( ! in_array( $column_name, array_keys( $this->managed_columns ) ) ) {
return;
}
$provider_object = $this->get_formatted_provider_info( $post_id );
if ( empty( $provider_object ) ) {
echo '—';
return;
}
// Bucket column.
if ( $column_name === 'as3cf_bucket' ) {
echo sprintf(
'%s<br>%s<br>%s',
$provider_object['provider_name'],
$provider_object['region'],
$provider_object['bucket']
);
if ( ! empty( $this->bucket_col_actions ) ) {
echo '<div class="row-actions">' . join( ' | ', $this->bucket_col_actions ) . '</div>';
}
}
// Access column.
if ( $column_name === 'as3cf_access' ) {
echo sprintf(
'%s',
$provider_object['acl']['name']
);
if ( ! empty( $this->access_col_actions ) ) {
echo '<div class="row-actions">' . join( ' | ', $this->access_col_actions ) . '</div>';
}
}
}
/**
* Renders the filter dropdowns in the media library list view.
*
* @handles restrict_manage_posts
*/
public function add_media_library_filters() {
$screen = get_current_screen();
if ( 'upload' !== $screen->base ) {
return;
}
$filters = $this->get_media_library_filters_config();
echo AS3CF_Utils::make_dropdown( $filters['as3cf_location'] );
echo AS3CF_Utils::make_dropdown( $filters['as3cf_access'] );
}
/**
* Filter the posts query when one of our filters are in use on the
* media library screen.
*
* @handles pre_get_posts
*
* @param WP_Query $query
*
* @return WP_Query
*/
public function media_library_filter_query( WP_Query $query ): WP_Query {
global $pagenow;
if ( empty( $query->query_vars["post_type"] ) || 'attachment' !== $query->query_vars["post_type"] ) {
return $query;
}
if ( ! in_array( $pagenow, array( 'upload.php', 'admin-ajax.php' ) ) ) {
return $query;
}
$location = $this->selected_filter_value( 'as3cf_location', 'all' );
$access = $this->selected_filter_value( 'as3cf_access', 'all' );
$orderby = $query->get( 'orderby' );
// If none of our filters are in play and sorting isn't by one of our columns, exit out.
if ( 'all' === $location && 'all' === $access && ! in_array( $orderby, array_keys( $this->managed_columns ) ) ) {
return $query;
}
add_filter( 'posts_join', array( $this, 'query_join_items_table' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'query_add_where' ), 10, 2 );
add_filter( 'posts_orderby', array( $this, 'query_add_orderby' ), 10, 2 );
return $query;
}
/**
* Join in the items table in WP_Query.
*
* @handles posts_join
*
* @param string $join
* @param WP_Query $query
*
* @return string
*/
public function query_join_items_table( string $join, WP_Query $query ): string {
global $wpdb;
$table = $wpdb->get_blog_prefix() . Item::ITEMS_TABLE;
$join .= " LEFT JOIN {$table} i ON (i.source_type = 'media-library' AND i.source_id = {$wpdb->posts}.ID) ";
return $join;
}
/**
* Add where clauses to posts query.
*
* @handles posts_where
*
* @param string $where
* @param WP_Query $query
*
* @return string
*/
public function query_add_where( string $where, WP_Query $query ): string {
global $wpdb;
$location = $this->selected_filter_value( 'as3cf_location', 'all' );
switch ( $location ) {
case 'all':
// intentionally empty
break;
case 'offloaded':
$where .= ' AND i.id IS NOT NULL ';
break;
case 'not_offloaded':
$where .= ' AND i.id IS NULL ';
break;
default:
$parts = explode( '|', $location );
if ( count( $parts ) === 2 ) {
array_walk( $parts, 'sanitize_key' );
$where .= $wpdb->prepare( ' AND (i.provider = %s AND i.bucket = %s) ', $parts[0], $parts[1] );
}
}
$access = $this->selected_filter_value( 'as3cf_access', 'all' );
switch ( $access ) {
case 'all':
// intentionally empty
break;
case 'private':
$where .= ' AND i.is_private = 1 ';
break;
case 'public':
$where .= ' AND i.is_private = 0 ';
break;
}
return $where;
}
/**
* Add ORDER BY clause to posts query.
*
* @handles posts_orderby
*
* @param string $orderby
* @param WP_Query $query
*
* @return string
*/
public function query_add_orderby( string $orderby, WP_Query $query ): string {
$direction = $query->get( 'order', 'ASC' );
switch ( $query->get( 'orderby' ) ) {
case 'as3cf_access':
$orderby = sprintf( 'i.is_private %s', $direction );
break;
case 'as3cf_bucket':
$orderby = sprintf( 'i.bucket %1$s, i.provider %1$s', $direction );
break;
}
return $orderby;
}
/**
* Get all providers and buckets currently in use.
*
* @return array
*/
private function get_providers_and_buckets(): array {
$blog_id = get_current_blog_id();
$counts = $this->as3cf->media_counts();
if ( ! empty( $counts['providers'][ $blog_id ] ) ) {
return $counts['providers'][ $blog_id ];
}
return array();
}
/**
* Update media counts cache with current provider and bucket usage.
*
* @param array $media_counts
* @param int $blog_id
* @param string $table_prefix
*
* @return array
*/
public function add_providers_and_buckets_to_media_counts( $media_counts, $blog_id, $table_prefix ): array {
global $wpdb;
if ( ! is_array( $media_counts ) || ! is_int( $blog_id ) || empty( $table_prefix ) || ! is_string( $table_prefix ) ) {
return $media_counts;
}
$table = $table_prefix . Item::ITEMS_TABLE;
$rows = $wpdb->get_results( "SELECT DISTINCT provider, bucket FROM $table ORDER BY provider, bucket" );
$providers = array_unique( array_map( function ( $row ) {
return $row->provider;
}, $rows ) );
$all_buckets = array();
foreach ( $providers as $provider_key ) {
/** @var Provider|null $provider_class */
$provider_class = $this->as3cf->get_provider_class( $provider_key );
$provider_name = is_null( $provider_class ) ? $provider_key : $provider_class::get_provider_service_name();
foreach ( $rows as $row ) {
if ( $row->provider !== $provider_key ) {
continue;
}
$bucket_key = $row->provider . '|' . $row->bucket;
$all_buckets[ $provider_name ][ $bucket_key ] = $row->bucket;
}
}
$media_counts['providers'][ $blog_id ] = $all_buckets;
return $media_counts;
}
/**
* Safely retrieve the selected filter value from a dropdown.
*
* @param string $key
* @param string $default
*
* @return string
*/
private function selected_filter_value( string $key, string $default ): string {
if ( wp_doing_ajax() ) {
if ( isset( $_REQUEST['query'][ $key ] ) ) {
$value = sanitize_text_field( $_REQUEST['query'][ $key ] );
}
} else {
if ( ! isset( $_REQUEST['filter_action'] ) || $_REQUEST['filter_action'] !== 'Filter' ) {
return $default;
}
if ( ! isset( $_REQUEST[ $key ] ) ) {
return $default;
}
$value = sanitize_text_field( $_REQUEST[ $key ] );
}
return ! empty( $value ) ? $value : $default;
}
/**
* Return values for the media filters.
*
* @return array
*/
private function get_media_library_filters_config(): array {
if ( ! empty( $this->media_library_filters ) ) {
return $this->media_library_filters;
}
// Locations dropdown.
$options = array(
'all' => _x( 'All locations', 'Media Library Filter option', 'amazon-s3-and-cloudfront' ),
'offloaded' => _x( 'Offloaded', 'Media Library Filter option', 'amazon-s3-and-cloudfront' ),
'not_offloaded' => _x( 'Not offloaded', 'Media Library Filter option', 'amazon-s3-and-cloudfront' ),
);
$options = array_merge( $options, $this->get_providers_and_buckets() );
$this->media_library_filters['as3cf_location'] = array(
'id' => 'as3cf_location',
'name' => 'as3cf_location',
'label' => __( 'Filter by Offload Media location', 'amazon-s3-and-cloudfront' ),
'selected' => $this->selected_filter_value( 'as3cf_location', 'all' ),
'options' => $options,
);
// Access dropdown.
$this->media_library_filters['as3cf_access'] = array(
'id' => 'as3cf_access',
'name' => 'as3cf_access',
'label' => __( 'Filter by Offload Media access level', 'amazon-s3-and-cloudfront' ),
'selected' => $this->selected_filter_value( 'as3cf_access', 'all' ),
'options' => array(
'all' => _x( 'All access', 'Media Library Filter option', 'amazon-s3-and-cloudfront' ),
'public' => _x( 'Public', 'Media Library Filter option', 'amazon-s3-and-cloudfront' ),
'private' => _x( 'Private', 'Media Library Filter option', 'amazon-s3-and-cloudfront' ),
),
);
return $this->media_library_filters;
}
/**
* Add URL entries for our summary counts.
*
* @handles as3cf_get_summary_counts
*
* @param array $summaries
*
* @return array
*/
public function add_urls_to_summary_counts( array $summaries ): array {
$attachments_url = add_query_arg(
array(
'mode' => 'list',
'filter_action' => 'Filter',
),
admin_url( 'upload.php' )
);
foreach ( $summaries as $idx => $summary ) {
if ( ! empty( $summary['type'] ) && Media_Library_Item::summary_type() === $summary['type'] ) {
$summaries[ $idx ]['offloaded_url'] = add_query_arg( 'as3cf_location', 'offloaded', $attachments_url );
$summaries[ $idx ]['not_offloaded_url'] = add_query_arg( 'as3cf_location', 'not_offloaded', $attachments_url );
break;
}
}
return $summaries;
}
}