Files
WPS3Media/classes/providers/storage/gcp-provider.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

912 lines
23 KiB
PHP

<?php
namespace DeliciousBrains\WP_Offload_Media\Providers\Storage;
use AS3CF_Plugin_Base;
use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Core\Exception\GoogleException;
use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Core\ServiceBuilder;
use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Storage\Bucket;
use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Storage\StorageClient;
use DeliciousBrains\WP_Offload_Media\Gcp\Google\Cloud\Storage\StorageObject;
use DeliciousBrains\WP_Offload_Media\Providers\Storage\Streams\GCP_GCS_Stream_Wrapper;
use Exception;
use WP_Error;
class GCP_Provider extends Storage_Provider {
/**
* @var ServiceBuilder
*/
private $cloud;
/**
* @var StorageClient
*/
private $storage;
/**
* @var string
*/
protected static $provider_name = 'Google Cloud Platform';
/**
* @var string
*/
protected static $provider_short_name = 'GCP';
/**
* Used in filters and settings.
*
* @var string
*/
protected static $provider_key_name = 'gcp';
/**
* @var string
*/
protected static $service_name = 'Google Cloud Storage';
/**
* @var string
*/
protected static $service_short_name = 'GCS';
/**
* Used in filters and settings.
*
* @var string
*/
protected static $service_key_name = 'gcs';
/**
* Optional override of "Provider Name" + "Service Name" for friendly name for service.
*
* @var string
*/
protected static $provider_service_name = 'Google Cloud Storage';
/**
* The slug for the service's quick start guide doc.
*
* @var string
*/
protected static $provider_service_quick_start_slug = 'google-cloud-storage-quick-start-guide';
/**
* @var array
*/
protected static $use_server_roles_constants = array(
'AS3CF_GCP_USE_GCE_IAM_ROLE',
);
/**
* @var array
*/
protected static $key_file_path_constants = array(
'AS3CF_GCP_KEY_FILE_PATH',
);
/**
* @var array
*/
protected static $regions = array(
'asia' => 'Multi-Region (Asia)',
'eu' => 'Multi-Region (EU)',
'us' => 'Multi-Region (US)',
'us-central1' => 'North America (Iowa)',
'us-east1' => 'North America (South Carolina)',
'us-east4' => 'North America (Northern Virginia)',
'us-east5' => 'North America (Columbus)',
'us-west1' => 'North America (Oregon)',
'us-west2' => 'North America (Los Angeles)',
'us-west3' => 'North America (Salt Lake City)',
'us-west4' => 'North America (Las Vegas)',
'us-south1' => 'North America (Dallas)',
'northamerica-northeast1' => 'North America (Montréal)',
'northamerica-northeast2' => 'North America (Toronto)',
'southamerica-east1' => 'South America (São Paulo)',
'southamerica-west1' => 'South America (Santiago)',
'europe-central2' => 'Europe (Warsaw)',
'europe-north1' => 'Europe (Finland)',
'europe-west1' => 'Europe (Belgium)',
'europe-west2' => 'Europe (London)',
'europe-west3' => 'Europe (Frankfurt)',
'europe-west4' => 'Europe (Netherlands)',
'europe-west6' => 'Europe (Zürich)',
'europe-west8' => 'Europe (Milan)',
'europe-west9' => 'Europe (Paris)',
'europe-west10' => 'Europe (Berlin)',
'europe-west12' => 'Europe (Turin)',
'europe-southwest1' => 'Europe (Madrid)',
'asia-east1' => 'Asia (Taiwan)',
'asia-east2' => 'Asia (Hong Kong)',
'asia-northeast1' => 'Asia (Tokyo)',
'asia-northeast2' => 'Asia (Osaka)',
'asia-northeast3' => 'Asia (Seoul)',
'asia-southeast1' => 'Asia (Singapore)',
'asia-south1' => 'India (Mumbai)',
'asia-south2' => 'India (Dehli)',
'asia-southeast2' => 'Indonesia (Jakarta)',
'me-central1' => 'Middle East (Doha)',
'me-central2' => 'Middle East (Dammam, Saudi Arabia)',
'me-west1' => 'Middle East (Tel Aviv)',
'australia-southeast1' => 'Australia (Sydney)',
'australia-southeast2' => 'Australia (Melbourne)',
'africa-south1' => 'Africa (Johannesburg)',
'asia1' => 'Dual-Region (Tokyo/Osaka)',
'eur4' => 'Dual-Region (Finland/Netherlands)',
'eur5' => 'Dual-Region (Belgium/London)',
'eur7' => 'Dual-Region (London/Frankfurt)',
'eur8' => 'Dual-Region (Frankfurt/Zürich)',
'nam4' => 'Dual-Region (Iowa/South Carolina)',
);
/**
* @var string
*/
protected static $default_region = 'us';
/**
* @var string
*/
protected $default_domain = 'storage.googleapis.com';
/**
* @var string
*/
protected $console_url = 'https://console.cloud.google.com/storage/browser/';
/**
* @var string
*/
protected $console_url_prefix_param = '/';
const PUBLIC_ACL = 'publicRead';
const PRIVATE_ACL = 'projectPrivate';
/**
* GCP_Provider constructor.
*
* @param AS3CF_Plugin_Base $as3cf
*/
public function __construct( AS3CF_Plugin_Base $as3cf ) {
parent::__construct( $as3cf );
// Autoloader.
require_once $as3cf->get_plugin_sdks_dir_path() . '/Gcp/autoload.php';
}
/**
* Returns default args array for the client.
*
* @return array
*/
protected function default_client_args() {
return array();
}
/**
* Process the args before instantiating a new client for the provider's SDK.
*
* @param array $args
*
* @return array
*/
protected function init_client_args( array $args ) {
return $args;
}
/**
* Instantiate a new client for the provider's SDK.
*
* @param array $args
*/
protected function init_client( array $args ) {
$this->cloud = new ServiceBuilder( $args );
}
/**
* Process the args before instantiating a new service specific client.
*
* @param array $args
*
* @return array
*/
protected function init_service_client_args( array $args ) {
return $args;
}
/**
* Instantiate a new service specific client.
*
* @param array $args
*
* @return StorageClient
*/
protected function init_service_client( array $args ) {
$this->storage = $this->cloud->storage( $args );
return $this->storage;
}
/**
* Make sure region "slug" fits expected format.
*
* @param string $region
*
* @return string
*/
public function sanitize_region( $region ) {
if ( ! is_string( $region ) ) {
// Don't translate any region errors
return $region;
}
return strtolower( $region );
}
/**
* Create bucket.
*
* @param array $args
*
* @throws GoogleException
*/
public function create_bucket( array $args ) {
$name = '';
if ( ! empty( $args['Bucket'] ) ) {
$name = $args['Bucket'];
unset( $args['Bucket'] );
}
if ( ! empty( $args['LocationConstraint'] ) ) {
$args['location'] = $args['LocationConstraint'];
unset( $args['LocationConstraint'] );
}
$this->storage->createBucket( $name, $args );
}
/**
* Check whether bucket exists.
*
* @param string $bucket
*
* @return bool
*/
public function does_bucket_exist( $bucket ) {
return $this->storage->bucket( $bucket )->exists();
}
/**
* Returns region for bucket.
*
* @param array $args
*
* @return string
*/
public function get_bucket_location( array $args ) {
$info = $this->storage->bucket( $args['Bucket'] )->info();
$region = empty( $info['location'] ) ? '' : $info['location'];
return $region;
}
/**
* List buckets.
*
* @param array $args
*
* @return array
* @throws GoogleException
*/
public function list_buckets( array $args = array() ) {
$result = array();
$buckets = $this->storage->buckets( $args );
if ( ! empty( $buckets ) ) {
/** @var Bucket $bucket */
foreach ( $buckets as $bucket ) {
$result['Buckets'][] = array(
'Name' => $bucket->name(),
);
}
}
return $result;
}
/**
* Check whether key exists in bucket.
*
* @param string $bucket
* @param string $key
* @param array $options
*
* @return bool
*/
public function does_object_exist( $bucket, $key, array $options = array() ) {
return $this->storage->bucket( $bucket )->object( $key )->exists( $options );
}
/**
* Get public "canned" ACL string.
*
* @return string
*/
public function get_public_acl() {
return static::PUBLIC_ACL;
}
/**
* Get private "canned" ACL string.
*
* @return string
*/
public function get_private_acl() {
return static::PRIVATE_ACL;
}
/**
* Download object, destination specified in args.
*
* @see https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-s3-2006-03-01.html#getobject
* @see https://googleapis.github.io/google-cloud-php/#/docs/google-cloud/v0.90.0/storage/storageobject?method=downloadToFile
*
* @param array $args
*/
public function get_object( array $args ) {
$this->storage->bucket( $args['Bucket'] )->object( $args['Key'] )->downloadToFile( $args['SaveAs'] );
}
/**
* Get object's URL.
*
* @param string $bucket
* @param string $key
* @param int $timestamp
* @param array $args
*
* @return string
*/
public function get_object_url( $bucket, $key, $timestamp, array $args = array() ) {
if ( empty( $timestamp ) || ! is_int( $timestamp ) || $timestamp < 0 ) {
$info = $this->storage->bucket( $bucket )->object( $key )->info();
$link = empty( $info['selfLink'] ) ? '' : $info['selfLink'];
return $link;
} else {
$options = array();
if ( ! empty( $args['BaseURL'] ) ) {
$options['cname'] = $args['BaseURL'];
}
return $this->storage->bucket( $bucket )->object( $key )->signedUrl( $timestamp, $options );
}
}
/**
* List objects.
*
* @param array $args
*
* @return array
*/
public function list_objects( array $args = array() ) {
$result = array();
$objects = $this->storage->bucket( $args['Bucket'] )->objects( $args['Prefix'] );
if ( ! empty( $objects ) ) {
/** @var StorageObject $object */
foreach ( $objects as $object ) {
$info = $object->info();
$result['Contents'][] = array(
'Key' => $object->name(),
'Size' => $info['size'],
);
}
}
return $result;
}
/**
* Update the ACL for an object.
*
* @param array $args
*
* @throws Exception
*/
public function update_object_acl( array $args ) {
if ( empty( $args['ACL'] ) ) {
throw new Exception( __METHOD__ . ' called without "ACL" arg.' );
}
$this->storage->bucket( $args['Bucket'] )->object( $args['Key'] )->update( array( 'predefinedAcl' => $args['ACL'] ) );
}
/**
* Update the ACL for multiple objects.
*
* @param array $items
*
* @return array Failures with elements Key and Message
*/
public function update_object_acls( array $items ) {
$failures = array();
// Unfortunately the GCP PHP SDK does not have batch operations.
foreach ( $items as $item ) {
try {
$this->update_object_acl( $item );
} catch ( Exception $e ) {
$failures[] = array(
'Key' => $item['Key'],
'Message' => $e->getMessage(),
);
}
}
return $failures;
}
/**
* Upload file to bucket.
*
* @param array $args
*
* @throws Exception
*/
public function upload_object( array $args ) {
if ( ! empty( $args['SourceFile'] ) ) {
$file = fopen( $args['SourceFile'], 'r' );
} elseif ( ! empty( $args['Body'] ) ) {
$file = $args['Body'];
} else {
throw new Exception( __METHOD__ . ' called without either "SourceFile" or "Body" arg.' );
}
$options = array(
'name' => $args['Key'],
);
if ( ! empty( $args['ACL'] ) ) {
$options['predefinedAcl'] = $args['ACL'];
}
if ( ! empty( $args['ContentType'] ) ) {
$options['metadata']['contentType'] = $args['ContentType'];
}
if ( ! empty( $args['CacheControl'] ) ) {
$options['metadata']['cacheControl'] = $args['CacheControl'];
}
// TODO: Potentially strip out known keys from $args and then put rest in $options['metadata']['metadata'].
$object = $this->storage->bucket( $args['Bucket'] )->upload( $file, $options ); // phpcs:ignore
}
/**
* Delete object from bucket.
*
* @param array $args
*/
public function delete_object( array $args ) {
$this->storage->bucket( $args['Bucket'] )->object( $args['Key'] )->delete();
}
/**
* Delete multiple objects from bucket.
*
* @param array $args
*/
public function delete_objects( array $args ) {
if ( isset( $args['Delete']['Objects'] ) ) {
$keys = $args['Delete']['Objects'];
} elseif ( isset( $args['Objects'] ) ) {
$keys = $args['Objects'];
}
if ( ! empty( $keys ) ) {
$bucket = $this->storage->bucket( $args['Bucket'] );
// Unfortunately the GCP PHP SDK does not have batch operations.
foreach ( $keys as $key ) {
$bucket->object( $key['Key'] )->delete();
}
}
}
/**
* Returns arrays of found keys for given bucket and prefix locations, retaining given array's integer based index.
*
* @param array $locations Array with attachment ID as key and Bucket and Prefix in an associative array as values.
*
* @return array
*/
public function list_keys( array $locations ) {
$keys = array();
$results = array_map( function ( $location ) {
return $this->storage->bucket( $location['Bucket'] )->objects( array(
'prefix' => $location['Prefix'],
'fields' => 'items/name',
) );
}, $locations );
foreach ( $results as $attachment_id => $objects ) {
/** @var StorageObject $object */
foreach ( $objects as $object ) {
$keys[ $attachment_id ][] = $object->name();
}
}
return $keys;
}
/**
* Copies objects into current bucket from another bucket hosted with provider.
*
* @param array $items
*
* @return array Failures with elements Key and Message
*/
public function copy_objects( array $items ) {
$failures = array();
// Unfortunately the GCP PHP SDK does not have batch operations.
foreach ( $items as $item ) {
list( $bucket, $key ) = explode( '/', urldecode( $item['CopySource'] ), 2 );
$options = array(
'name' => $item['Key'],
);
if ( ! empty( $item['ACL'] ) ) {
$options['predefinedAcl'] = $item['ACL'];
}
try {
$this->storage->bucket( $bucket )->object( $key )->copy( $item['Bucket'], $options );
} catch ( Exception $e ) {
$failures[] = array(
'Key' => $item['Key'],
'Message' => $e->getMessage(),
);
}
}
return $failures;
}
/**
* Generate the stream wrapper protocol
*
* @param string $region
*
* @return string
*/
protected function get_stream_wrapper_protocol( $region ) {
$protocol = 'gs';
// TODO: Determine whether same protocol for all regions is ok.
// Assumption not as each may have client instance, hence keeping this for time being.
$protocol .= str_replace( '-', '', $region );
return $protocol;
}
/**
* Register a stream wrapper for specific region.
*
* @param string $region
*
* @return bool
*/
public function register_stream_wrapper( $region ) {
$protocol = $this->get_stream_wrapper_protocol( $region );
// Register the region specific stream wrapper to be used by plugins
GCP_GCS_Stream_Wrapper::register( $this->storage, $protocol );
return true;
}
/**
* Check that a bucket and key can be written to.
*
* @param string $bucket
* @param string $key
* @param string $file_contents
*
* @return bool|string Error message on unexpected exception
*/
public function can_write( $bucket, $key, $file_contents ) {
try {
// Attempt to create the test file.
$this->upload_object(
static::filter_object_meta(
array(
'Bucket' => $bucket,
'Key' => $key,
'Body' => $file_contents,
)
)
);
// delete it straight away if created
$this->delete_object( array(
'Bucket' => $bucket,
'Key' => $key,
) );
return true;
} catch ( Exception $e ) {
// If we encounter an error that isn't from Google, throw that error.
if ( ! $e instanceof GoogleException ) {
return $e->getMessage();
}
}
return false;
}
/**
* Get the region specific prefix for raw URL
*
* @param string $region
* @param null|int $expires
*
* @return string
*/
protected function url_prefix( $region = '', $expires = null ) {
return '';
}
/**
* Get the url domain for the files
*
* @param string $domain Likely prefixed with region
* @param string $bucket
* @param string $region
* @param int $expires
* @param array $args Allows you to specify custom URL settings
*
* @return string
*/
protected function url_domain( $domain, $bucket, $region = '', $expires = null, $args = array() ) {
if (
apply_filters(
'as3cf_' . static::get_provider_key_name() . '_' . static::get_service_key_name() . '_bucket_in_path',
false !== strpos( $bucket, '.' )
)
) {
$domain = $domain . '/' . $bucket;
} else {
// TODO: Is this mode allowed for GCS native URLs?
$domain = $bucket . '.' . $domain;
}
return $domain;
}
/**
* Get the suffix param to append to the link to the provider's console.
*
* @param string $bucket
* @param string $prefix
* @param string $region
*
* @return string
*/
protected function get_console_url_suffix_param(
string $bucket = '',
string $prefix = '',
string $region = ''
): string {
if ( ! empty( $this->get_project_id() ) ) {
return '?project=' . $this->get_project_id();
}
return '';
}
/**
* Get the Project ID for the current client.
*
* @return string|null
*/
private function get_project_id() {
static $project_id = null;
// If not already grabbed, get project id from key file data but only if client properly instantiated.
if ( null === $project_id && ! empty( $this->storage ) && $this->use_key_file() ) {
$key_file_path = $this->get_key_file_path();
if ( ! empty( $key_file_path ) && file_exists( $key_file_path ) ) {
$key_file_contents = json_decode( file_get_contents( $key_file_path ), true );
if ( ! empty( $key_file_contents['project_id'] ) ) {
$project_id = $key_file_contents['project_id'];
return $project_id;
}
}
$key_file_contents = $this->get_key_file();
if ( is_array( $key_file_contents ) && ! empty( $key_file_contents['project_id'] ) ) {
$project_id = $key_file_contents['project_id'];
return $project_id;
}
}
return $project_id;
}
/**
* Read key file contents from path and convert it to the appropriate format for this provider.
*
* @param string $path
*
* @return mixed
*/
protected function get_key_file_path_contents( string $path ) {
$notice_id = 'validate-key-file-path';
$notice_args = array(
'type' => 'error',
'only_show_in_settings' => true,
'only_show_on_tab' => 'media',
'hide_on_parent' => true,
'custom_id' => $notice_id,
);
$content = json_decode( file_get_contents( $path ), true );
if ( empty( $content ) ) {
$this->as3cf->notices->add_notice(
__( 'Media cannot be offloaded due to invalid JSON in the key file.', 'amazon-s3-and-cloudfront' ),
$notice_args
);
return false;
}
return $content;
}
/**
* Google specific validation of the key file contents.
*
* @param array $key_file_content
*
* @return bool
*/
public function validate_key_file_content( $key_file_content ): bool {
$notice_id = 'validate-key-file-content';
$this->as3cf->notices->remove_notice_by_id( $notice_id );
$notice_args = array(
'type' => 'error',
'only_show_in_settings' => true,
'only_show_on_tab' => 'media',
'hide_on_parent' => true,
'custom_id' => $notice_id,
);
if ( ! isset( $key_file_content['project_id'] ) ) {
$this->as3cf->notices->add_notice(
sprintf(
__(
'Media cannot be offloaded due to a missing <code>project_id</code> field which may be the result of an old or obsolete key file. <a href="%1$s" target="_blank">Create a new key file</a>',
'amazon-s3-and-cloudfront'
),
static::get_provider_service_quick_start_url() . '#service-account-key-file'
),
$notice_args
);
return false;
}
if ( ! isset( $key_file_content['private_key'] ) ) {
$this->as3cf->notices->add_notice(
sprintf(
__(
'Media cannot be offloaded due to a missing <code>private_key</code> field in the key file. <a href="%1$s" target="_blank"">Create a new key file</a>',
'amazon-s3-and-cloudfront'
),
static::get_provider_service_quick_start_url() . '#service-account-key-file'
),
$notice_args
);
return false;
}
if ( ! isset( $key_file_content['type'] ) ) {
$this->as3cf->notices->add_notice(
sprintf(
__(
'Media cannot be offloaded due to a missing <code>type</code> field in the key file. <a href="%1$s" target="_blank">Create a new key file</a>',
'amazon-s3-and-cloudfront'
),
static::get_provider_service_quick_start_url() . '#service-account-key-file'
),
$notice_args
);
return false;
}
if ( ! isset( $key_file_content['client_email'] ) ) {
$this->as3cf->notices->add_notice(
sprintf(
__(
'Media cannot be offloaded due to a missing <code>client_email</code> field in the key file. <a href="%1$s" target="_blank">Create a new key file</a>',
'amazon-s3-and-cloudfront'
),
static::get_provider_service_quick_start_url() . '#service-account-key-file'
),
$notice_args
);
return false;
}
return true;
}
/**
* Prepare the bucket error.
*
* @param WP_Error $object
* @param bool $single Are we dealing with a single bucket?
*
* @return string
*/
public function prepare_bucket_error( WP_Error $object, bool $single = true ): string {
if ( false !== strpos( $object->get_error_message(), "OpenSSL unable to sign" ) ) {
return sprintf(
__(
'Media cannot be offloaded due to an invalid OpenSSL Private Key. <a href="%1$s" target="_blank">Update the key file</a>',
'amazon-s3-and-cloudfront'
),
static::get_provider_service_quick_start_url() . '#service-account-key-file'
);
}
// This may be a JSON error message from Google.
$message = json_decode( $object->get_error_message() );
if ( ! is_null( $message ) ) {
if ( isset( $message->error ) && 'invalid_grant' === $message->error ) {
return sprintf(
__(
'Media cannot be offloaded using the provided service account. <a href="%1$s" target="_blank">Read more</a>',
'amazon-s3-and-cloudfront'
),
static::get_provider_service_quick_start_url() . '#service-account-key-file'
);
}
if ( isset( $message->error->code ) && 404 === $message->error->code ) {
return sprintf(
__(
'Media cannot be offloaded because a bucket with the configured name does not exist. <a href="%1$s">Enter a different bucket</a>',
'amazon-s3-and-cloudfront'
),
'#/storage/bucket'
);
}
}
// Fallback to generic error parsing.
return parent::prepare_bucket_error( $object, $single );
}
}