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,501 @@
<?php
use DeliciousBrains\WP_Offload_Media\Items\Item;
class AS3CF_Local_To_S3 extends AS3CF_Filter {
/**
* @inheritDoc
*/
public function setup() {
parent::setup();
// EDD
add_filter( 'edd_download_files', array( $this, 'filter_edd_download_files' ) );
// Customizer
add_filter( 'theme_mod_background_image', array( $this, 'filter_customizer_image' ) );
add_filter( 'theme_mod_header_image', array( $this, 'filter_customizer_image' ) );
add_filter( 'customize_value_custom_css', array( $this, 'filter_customize_value_custom_css' ), 10, 2 );
add_filter( 'wp_get_custom_css', array( $this, 'filter_wp_get_custom_css' ), 10, 2 );
// Posts
add_action( 'the_post', array( $this, 'filter_post_data' ) );
add_filter( 'content_pagination', array( $this, 'filter_content_pagination' ) );
add_filter( 'the_content', array( $this, 'filter_post' ), 100 );
add_filter( 'the_excerpt', array( $this, 'filter_post' ), 100 );
add_filter( 'rss_enclosure', array( $this, 'filter_post' ), 100 );
add_filter( 'content_edit_pre', array( $this, 'filter_post' ) );
add_filter( 'excerpt_edit_pre', array( $this, 'filter_post' ) );
add_filter( 'as3cf_filter_post_local_to_s3', array( $this, 'filter_post' ) ); // Backwards compatibility
add_filter( 'as3cf_filter_post_local_to_provider', array( $this, 'filter_post' ) );
// Widgets
add_filter( 'widget_form_callback', array( $this, 'filter_widget_display' ) );
add_filter( 'widget_display_callback', array( $this, 'filter_widget_display' ) );
if ( function_exists( 'is_wp_version_compatible' ) && is_wp_version_compatible( '5.8' ) ) {
add_filter( 'customize_value_widget_block', array( $this, 'filter_customize_value_widget_block' ) );
add_filter( 'widget_block_content', array( $this, 'filter_widget_block_content' ) );
}
// Edit Media page
add_filter( 'set_url_scheme', array( $this, 'set_url_scheme' ), 10, 3 );
// Blocks
if ( function_exists( 'is_wp_version_compatible' ) && is_wp_version_compatible( '5.9' ) ) {
add_filter( 'render_block', array( $this, 'filter_post' ), 100 );
add_filter( 'get_block_templates', array( $this, 'filter_get_block_templates' ), 100, 3 );
add_filter( 'get_block_template', array( $this, 'filter_get_block_template' ), 100, 3 );
}
}
/**
* Filter customize value custom CSS.
*
* @param mixed $value
* @param WP_Customize_Custom_CSS_Setting $setting
*
* @return string
*/
public function filter_customize_value_custom_css( $value, $setting ) {
return $this->filter_custom_css( $value, $setting->stylesheet );
}
/**
* Filter `wp_get_custom_css`.
*
* @param string $css
* @param string $stylesheet
*
* @return string
*/
public function filter_wp_get_custom_css( $css, $stylesheet ) {
return $this->filter_custom_css( $css, $stylesheet );
}
/**
* Filter post data.
*
* @param WP_Post $post
*/
public function filter_post_data( $post ) {
global $pages;
$cache = $this->get_post_cache( $post->ID );
$to_cache = array();
if ( is_array( $pages ) && 1 === count( $pages ) && ! empty( $pages[0] ) ) {
// Post already filtered and available on global $page array, continue
$post->post_content = $pages[0];
} else {
$post->post_content = $this->process_content( $post->post_content, $cache, $to_cache );
}
$post->post_excerpt = $this->process_content( $post->post_excerpt, $cache, $to_cache );
$this->maybe_update_post_cache( $to_cache );
}
/**
* Filter content pagination.
*
* @param array $pages
*
* @return array
*/
public function filter_content_pagination( $pages ) {
$cache = $this->get_post_cache();
$to_cache = array();
foreach ( $pages as $key => $page ) {
$pages[ $key ] = $this->process_content( $page, $cache, $to_cache );
}
$this->maybe_update_post_cache( $to_cache );
return $pages;
}
/**
* Filter widget display.
*
* @param array $instance
*
* @return array
*/
public function filter_widget_display( $instance ) {
return $this->handle_widget( $instance );
}
/**
* Filters the content of the block widget during initial load of the customizer.
*
* @param array $value The widget block.
*
* @return array
*/
public function filter_customize_value_widget_block( $value ) {
return $this->handle_widget( $value );
}
/**
* Filters the content of the block widget before output.
*
* @param string $content The widget content.
*
* @return string
*/
public function filter_widget_block_content( $content ) {
if ( empty( $content ) ) {
return $content;
}
$cache = $this->get_option_cache();
$to_cache = array();
$changed_content = $this->process_content( $content, $cache, $to_cache );
if ( ! empty( $changed_content ) && $changed_content !== $content ) {
$content = $changed_content;
}
$this->maybe_update_option_cache( $to_cache );
return $content;
}
/**
* Does URL need replacing?
*
* @param string $url
*
* @return bool
*/
public function url_needs_replacing( $url ) {
if ( str_replace( $this->get_bare_upload_base_urls(), '', $url ) === $url ) {
// Remote URL, no replacement needed
return false;
}
// Local URL, perform replacement
return true;
}
/**
* Get URL
*
* @param array $item_source
* @param null|string $object_key
*
* @return bool|string
*/
protected function get_url( $item_source, $object_key = null ) {
if ( Item::is_empty_item_source( $item_source ) ) {
return false;
}
/**
* Return the provider URL for an item
*
* @param string|false $url Url for the item, false if no URL can be determined
* @param array $item_source Associative array describing the item, guaranteed keys:-
* id: source item's unique integer id
* source_type: source item's string type identifier
* @param string|null $object_key Object key (size) describing what sub file of an item to return url for
*/
return apply_filters( 'as3cf_get_provider_url_for_item_source', false, $item_source, $object_key );
}
/**
* Get base URL.
*
* @param array $item_source
*
* @return string|false
*/
protected function get_base_url( $item_source ) {
if ( Item::is_empty_item_source( $item_source ) ) {
return false;
}
/**
* Return the local URL for an item
*
* @param string|false $url Url for the item, false if no URL can be determined
* @param array $item_source Associative array describing the item, guaranteed keys:-
* id: source item's unique integer id
* source_type: source item's string type identifier
* @param string|null $object_key Object key (size) describing what sub file of an item to return url for
*/
return apply_filters( 'as3cf_get_local_url_for_item_source', false, $item_source, null );
}
/**
* Get item source descriptor from URL.
*
* @param string $url
*
* @return bool|array
*/
public function get_item_source_from_url( $url ) {
$results = $this->get_item_sources_from_urls( array( $url ) );
if ( empty( $results ) ) {
return false;
}
foreach ( $results as $result ) {
if ( $result ) {
return $result;
}
}
return false;
}
/**
* Get item source descriptors from URLs.
*
* @param array $urls
*
* @return array url => item source descriptor array (or false)
*/
protected function get_item_sources_from_urls( $urls ) {
$results = array();
if ( empty( $urls ) ) {
return $results;
}
if ( ! is_array( $urls ) ) {
$urls = array( $urls );
}
$query_set = array();
$paths = array();
$full_urls = array();
// Quickly parse given URLs to add versions without size as we should lookup with size info first as that could be the "full" size.
foreach ( $urls as $url ) {
$query_set[] = $url;
$size_removed = AS3CF_Utils::remove_size_from_filename( $url );
if ( $url !== $size_removed ) {
$query_set[] = $size_removed;
}
}
foreach ( $query_set as $url ) {
// Path to search for in query set should be based on bare URL.
$bare_url = AS3CF_Utils::remove_scheme( $url );
// There can be multiple URLs in the query set that belong to the same full URL for the Media Library item.
$full_url = AS3CF_Utils::remove_size_from_filename( $bare_url );
if ( isset( $this->query_cache[ $full_url ] ) ) {
// ID already cached, use it.
$results[ $url ] = $this->query_cache[ $full_url ];
continue;
}
$path = AS3CF_Utils::decode_filename_in_path( ltrim( str_replace( $this->get_bare_upload_base_urls(), '', $bare_url ), '/' ) );
$paths[ $path ] = $full_url;
$full_urls[ $full_url ][] = $url;
}
if ( ! empty( $paths ) ) {
$as3cf_items = Item::get_by_source_path( array_keys( $paths ) );
if ( ! empty( $as3cf_items ) ) {
/* @var Item $as3cf_item */
foreach ( $as3cf_items as $as3cf_item ) {
// Each returned item may have matched on either the source_path or original_source_path.
// Because the base image file name of a thumbnail might match the primary rather scaled or rotated full image
// it's possible that both source paths are used by separate URLs.
foreach ( array( $as3cf_item->source_path(), $as3cf_item->original_source_path() ) as $source_path ) {
if ( ! empty( $paths[ $source_path ] ) ) {
$matched_full_url = $paths[ $source_path ];
if ( ! empty( $full_urls[ $matched_full_url ] ) ) {
$item_source = array(
'id' => $as3cf_item->source_id(),
'source_type' => $as3cf_item->source_type(),
);
$this->query_cache[ $matched_full_url ] = $item_source;
foreach ( $full_urls[ $matched_full_url ] as $url ) {
$results[ $url ] = $item_source;
}
unset( $full_urls[ $matched_full_url ] );
}
}
}
}
}
// No more item IDs found, set remaining results as false.
if ( count( $query_set ) !== count( $results ) ) {
foreach ( $full_urls as $full_url => $schema_urls ) {
foreach ( $schema_urls as $url ) {
if ( ! array_key_exists( $url, $results ) ) {
$this->query_cache[ $full_url ] = false;
$results[ $url ] = false;
}
}
}
}
}
return $results;
}
/**
* Normalize find value.
*
* @param string $url
*
* @return string
*/
protected function normalize_find_value( $url ) {
return AS3CF_Utils::decode_filename_in_path( $url );
}
/**
* Normalize replace value.
*
* @param string $url
*
* @return string
*/
protected function normalize_replace_value( $url ) {
return AS3CF_Utils::encode_filename_in_path( $url );
}
/**
* Post process content.
*
* @param string $content
*
* @return string
*/
protected function post_process_content( $content ) {
$content = AS3CF_Utils::maybe_fix_serialized_string( $content );
return $content;
}
/**
* Pre replace content.
*
* @param string $content
*
* @return string
*/
protected function pre_replace_content( $content ) {
$uploads = wp_upload_dir();
$base_url = AS3CF_Utils::remove_scheme( $uploads['baseurl'] );
return $this->remove_aws_query_strings( $content, $base_url );
}
/**
* Each time a URL is replaced this function is called to allow for logging or further updates etc.
*
* @param string $find URL with no scheme.
* @param string $replace URL with no scheme.
* @param string $content
*
* @return string
*/
protected function url_replaced( $find, $replace, $content ) {
if ( (bool) $this->as3cf->get_setting( 'force-https' ) ) {
$content = str_replace( 'http:' . $replace, 'https:' . $replace, $content );
}
return $content;
}
/**
* Might need to re-fix remote URL's schema if WordPress core has substituted in HTTP but HTTPS is required.
*
* @param string $url
* @param string $scheme
* @param string $orig_scheme
*
* @return string
*/
public function set_url_scheme( $url, $scheme, $orig_scheme ) {
if (
'http' === $scheme && empty( $orig_scheme ) &&
$this->as3cf->get_setting( 'force-https' ) &&
$this->should_filter_content() &&
! $this->url_needs_replacing( $url )
) {
// Check that it's one of ours and not external.
$parts = AS3CF_Utils::parse_url( $url );
if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || 'http' !== $parts['scheme'] ) {
return $url;
}
$ours = false;
if ( $this->as3cf->get_setting( 'enable-delivery-domain' ) && $this->as3cf->get_setting( 'delivery-domain', '' ) === $parts['host'] ) {
$ours = true;
} elseif ( false !== strpos( $parts['host'], $this->as3cf->get_storage_provider()->get_domain() ) ) {
$ours = true;
}
if ( $ours ) {
return substr_replace( $url, 'https', 0, 4 );
}
}
return $url;
}
/**
* Filters the array of queried block templates array after they've been fetched.
*
* @param WP_Block_Template[] $query_result Array of found block templates.
* @param array $query Arguments to retrieve templates.
* @param string $template_type wp_template or wp_template_part.
*
* @return WP_Block_Template[]
*/
public function filter_get_block_templates( $query_result, $query, $template_type ) {
if ( empty( $query_result ) ) {
return $query_result;
}
foreach ( $query_result as $block_template ) {
$block_template = $this->filter_get_block_template( $block_template, $block_template->id, $template_type );
}
return $query_result;
}
/**
* Filters the queried block template object after it's been fetched.
*
* @param WP_Block_Template|null $block_template The found block template, or null if there isn't one.
* @param string $id Template unique identifier (example: theme_slug//template_slug).
* @param string $template_type Template type: `'wp_template'` or '`wp_template_part'`.
*
* @return WP_Block_Template|null
*/
public function filter_get_block_template( $block_template, $id, $template_type ) {
if ( empty( $block_template ) ) {
return $block_template;
}
$content = $block_template->content;
if ( empty( $content ) ) {
return $block_template;
}
$content = $this->filter_post( $content );
if ( ! empty( $content ) && $content !== $block_template->content ) {
$block_template->content = $content;
}
return $block_template;
}
}

View File

@@ -0,0 +1,266 @@
<?php
use DeliciousBrains\WP_Offload_Media\Items\Item;
class AS3CF_S3_To_Local extends AS3CF_Filter {
/**
* @inheritDoc
*/
public function setup() {
parent::setup();
// EDD
add_filter( 'edd_metabox_save_edd_download_files', array( $this, 'filter_edd_download_files' ) );
// Customizer
add_filter( 'pre_set_theme_mod_background_image', array( $this, 'filter_customizer_image' ), 10, 2 );
add_filter( 'pre_set_theme_mod_header_image', array( $this, 'filter_customizer_image' ), 10, 2 );
add_filter( 'pre_set_theme_mod_header_image_data', array( $this, 'filter_header_image_data' ), 10, 2 );
add_filter( 'update_custom_css_data', array( $this, 'filter_update_custom_css_data' ), 10, 2 );
// Posts
add_filter( 'content_save_pre', array( $this, 'filter_post' ) );
add_filter( 'excerpt_save_pre', array( $this, 'filter_post' ) );
add_filter( 'as3cf_filter_post_s3_to_local', array( $this, 'filter_post' ) ); // Backwards compatibility
add_filter( 'as3cf_filter_post_provider_to_local', array( $this, 'filter_post' ) );
// Widgets
add_filter( 'widget_update_callback', array( $this, 'filter_widget_save' ) );
add_filter( 'pre_update_option_widget_block', array( $this, 'filter_widget_block_save' ) );
}
/**
* Filter update custom CSS data.
*
* @param array $data
* @param array $args
*
* @return array
*/
public function filter_update_custom_css_data( $data, $args ) {
$data['css'] = $this->filter_custom_css( $data['css'], $args['stylesheet'] );
return $data;
}
/**
* Filter widget on save.
*
* @param array $instance
*
* @return array
*/
public function filter_widget_save( $instance ) {
return $this->handle_widget( $instance );
}
/**
* Filter widget block on save.
*
* @param array $value The new, unserialized option value.
*
* @return array
*/
public function filter_widget_block_save( $value ) {
if ( empty( $value ) || ! is_array( $value ) ) {
return $value;
}
foreach ( $value as $idx => $section ) {
$value[ $idx ] = $this->handle_widget( $section );
}
return $value;
}
/**
* Should filter content.
*
* @return bool
*/
protected function should_filter_content() {
return true;
}
/**
* Does URL need replacing?
*
* @param string $url
*
* @return bool
*/
public function url_needs_replacing( $url ) {
if ( str_replace( $this->get_bare_upload_base_urls(), '', $url ) !== $url ) {
// Local URL, no replacement needed.
return false;
}
if ( str_replace( $this->get_remote_domains(), '', $url ) === $url ) {
// Not a known remote URL, no replacement needed.
return false;
}
// Remote URL, perform replacement
return true;
}
/**
* Get URL
*
* @param array $item_source
* @param null|string $object_key
*
* @return bool|string
*/
protected function get_url( $item_source, $object_key = null ) {
if ( Item::is_empty_item_source( $item_source ) ) {
return false;
}
/**
* Return the local URL for an item
*
* @param string|false $url Url for the item, false if no URL can be determined
* @param array $item_source Associative array describing the item, guaranteed keys:-
* id: source item's unique integer id
* source_type: source item's string type identifier
* @param string|null $object_key Object key (size) describing what sub file of an item to return url for
*/
return apply_filters( 'as3cf_get_local_url_for_item_source', false, $item_source, $object_key );
}
/**
* Get base URL.
*
* @param array $item_source
*
* @return string|false
*/
protected function get_base_url( $item_source ) {
if ( Item::is_empty_item_source( $item_source ) ) {
return false;
}
/**
* Return the provider URL for an item
*
* @param string|false $url Url for the item, false if no URL can be determined
* @param array $item_source Associative array describing the item, guaranteed keys:-
* id: source item's unique integer id
* source_type: source item's string type identifier
* @param string|null $object_key Object key (size) describing what sub file of an item to return url for
*/
return apply_filters( 'as3cf_get_provider_url_for_item_source', false, $item_source, null );
}
/**
* Get item source descriptor from URL.
*
* @param string $url
*
* @return bool|array
*/
public function get_item_source_from_url( $url ) {
// Result for sized URL already cached in request, return it.
if ( isset( $this->query_cache[ $url ] ) ) {
return $this->query_cache[ $url ];
}
$item_source = Item::get_item_source_by_remote_url( $url );
if ( ! Item::is_empty_item_source( $item_source ) ) {
$this->query_cache[ $url ] = $item_source;
return $item_source;
}
$full_url = AS3CF_Utils::remove_size_from_filename( $url );
// If we've already tried to find this URL above because it didn't have a size suffix, cache and return.
if ( $url === $full_url ) {
$this->query_cache[ $url ] = $item_source;
return $item_source;
}
// Result for URL already cached in request whether found or not, return it.
if ( isset( $this->query_cache[ $full_url ] ) ) {
return $this->query_cache[ $full_url ];
}
$item_source = Item::get_item_source_by_remote_url( $full_url );
$this->query_cache[ $full_url ] = ! Item::is_empty_item_source( $item_source ) ? $item_source : false;
return $this->query_cache[ $full_url ];
}
/**
* Get item source descriptors from URLs.
*
* @param array $urls
*
* @return array url => item source descriptor (or false)
*/
protected function get_item_sources_from_urls( $urls ) {
$results = array();
if ( empty( $urls ) ) {
return $results;
}
if ( ! is_array( $urls ) ) {
$urls = array( $urls );
}
foreach ( $urls as $url ) {
$results[ $url ] = $this->get_item_source_from_url( $url );
}
return $results;
}
/**
* Normalize find value.
*
* @param string $url
*
* @return string
*/
protected function normalize_find_value( $url ) {
return AS3CF_Utils::encode_filename_in_path( $url );
}
/**
* Normalize replace value.
*
* @param string $url
*
* @return string
*/
protected function normalize_replace_value( $url ) {
return AS3CF_Utils::decode_filename_in_path( $url );
}
/**
* Post process content.
*
* @param string $content
*
* @return string
*/
protected function post_process_content( $content ) {
$content = $this->remove_aws_query_strings( $content );
$content = AS3CF_Utils::maybe_fix_serialized_string( $content );
return $content;
}
/**
* Pre replace content.
*
* @param string $content
*
* @return string
*/
protected function pre_replace_content( $content ) {
return $content;
}
}