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
274 lines
9.6 KiB
PHP
274 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace DeliciousBrains\WP_Offload_Media\Aws3\Aws\S3;
|
|
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Aws\Api\Parser\PayloadParserTrait;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Aws\CommandInterface;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Aws\Exception\AwsException;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Aws\HandlerList;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Aws\ResultInterface;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Aws\S3\Exception\PermanentRedirectException;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Aws\S3\Exception\S3Exception;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\GuzzleHttp\Promise\PromiseInterface;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\GuzzleHttp\Promise\RejectedPromise;
|
|
use DeliciousBrains\WP_Offload_Media\Aws3\Psr\Http\Message\ResponseInterface;
|
|
/**
|
|
* A trait providing S3-specific functionality. This is meant to be used in
|
|
* classes implementing \Aws\S3\S3ClientInterface
|
|
*/
|
|
trait S3ClientTrait
|
|
{
|
|
use PayloadParserTrait;
|
|
/**
|
|
* @see S3ClientInterface::upload()
|
|
*/
|
|
public function upload($bucket, $key, $body, $acl = 'private', array $options = [])
|
|
{
|
|
return $this->uploadAsync($bucket, $key, $body, $acl, $options)->wait();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::uploadAsync()
|
|
*/
|
|
public function uploadAsync($bucket, $key, $body, $acl = 'private', array $options = [])
|
|
{
|
|
return (new ObjectUploader($this, $bucket, $key, $body, $acl, $options))->promise();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::copy()
|
|
*/
|
|
public function copy($fromB, $fromK, $destB, $destK, $acl = 'private', array $opts = [])
|
|
{
|
|
return $this->copyAsync($fromB, $fromK, $destB, $destK, $acl, $opts)->wait();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::copyAsync()
|
|
*/
|
|
public function copyAsync($fromB, $fromK, $destB, $destK, $acl = 'private', array $opts = [])
|
|
{
|
|
$source = ['Bucket' => $fromB, 'Key' => $fromK];
|
|
if (isset($opts['version_id'])) {
|
|
$source['VersionId'] = $opts['version_id'];
|
|
}
|
|
$destination = ['Bucket' => $destB, 'Key' => $destK];
|
|
return (new ObjectCopier($this, $source, $destination, $acl, $opts))->promise();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::registerStreamWrapper()
|
|
*/
|
|
public function registerStreamWrapper()
|
|
{
|
|
StreamWrapper::register($this);
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::registerStreamWrapperV2()
|
|
*/
|
|
public function registerStreamWrapperV2()
|
|
{
|
|
StreamWrapper::register($this, 's3', null, \true);
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::deleteMatchingObjects()
|
|
*/
|
|
public function deleteMatchingObjects($bucket, $prefix = '', $regex = '', array $options = [])
|
|
{
|
|
$this->deleteMatchingObjectsAsync($bucket, $prefix, $regex, $options)->wait();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::deleteMatchingObjectsAsync()
|
|
*/
|
|
public function deleteMatchingObjectsAsync($bucket, $prefix = '', $regex = '', array $options = [])
|
|
{
|
|
if (!$prefix && !$regex) {
|
|
return new RejectedPromise(new \RuntimeException('A prefix or regex is required.'));
|
|
}
|
|
$params = ['Bucket' => $bucket, 'Prefix' => $prefix];
|
|
$iter = $this->getIterator('ListObjects', $params);
|
|
if ($regex) {
|
|
$iter = \DeliciousBrains\WP_Offload_Media\Aws3\Aws\filter($iter, function ($c) use($regex) {
|
|
return \preg_match($regex, $c['Key']);
|
|
});
|
|
}
|
|
return BatchDelete::fromIterator($this, $bucket, $iter, $options)->promise();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::uploadDirectory()
|
|
*/
|
|
public function uploadDirectory($directory, $bucket, $keyPrefix = null, array $options = [])
|
|
{
|
|
$this->uploadDirectoryAsync($directory, $bucket, $keyPrefix, $options)->wait();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::uploadDirectoryAsync()
|
|
*/
|
|
public function uploadDirectoryAsync($directory, $bucket, $keyPrefix = null, array $options = [])
|
|
{
|
|
$d = "s3://{$bucket}" . ($keyPrefix ? '/' . \ltrim($keyPrefix, '/') : '');
|
|
return (new Transfer($this, $directory, $d, $options))->promise();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::downloadBucket()
|
|
*/
|
|
public function downloadBucket($directory, $bucket, $keyPrefix = '', array $options = [])
|
|
{
|
|
$this->downloadBucketAsync($directory, $bucket, $keyPrefix, $options)->wait();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::downloadBucketAsync()
|
|
*/
|
|
public function downloadBucketAsync($directory, $bucket, $keyPrefix = '', array $options = [])
|
|
{
|
|
$s = "s3://{$bucket}" . ($keyPrefix ? '/' . \ltrim($keyPrefix, '/') : '');
|
|
return (new Transfer($this, $s, $directory, $options))->promise();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::determineBucketRegion()
|
|
*/
|
|
public function determineBucketRegion($bucketName)
|
|
{
|
|
return $this->determineBucketRegionAsync($bucketName)->wait();
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::determineBucketRegionAsync()
|
|
*
|
|
* @param string $bucketName
|
|
*
|
|
* @return PromiseInterface
|
|
*/
|
|
public function determineBucketRegionAsync($bucketName)
|
|
{
|
|
$command = $this->getCommand('HeadBucket', ['Bucket' => $bucketName]);
|
|
$handlerList = clone $this->getHandlerList();
|
|
$handlerList->remove('s3.permanent_redirect');
|
|
$handlerList->remove('signer');
|
|
$handler = $handlerList->resolve();
|
|
return $handler($command)->then(static function (ResultInterface $result) {
|
|
return $result['@metadata']['headers']['x-amz-bucket-region'];
|
|
}, function (AwsException $e) {
|
|
$response = $e->getResponse();
|
|
if ($response === null) {
|
|
throw $e;
|
|
}
|
|
if ($e->getAwsErrorCode() === 'AuthorizationHeaderMalformed') {
|
|
$region = $this->determineBucketRegionFromExceptionBody($response);
|
|
if (!empty($region)) {
|
|
return $region;
|
|
}
|
|
throw $e;
|
|
}
|
|
return $response->getHeaderLine('x-amz-bucket-region');
|
|
});
|
|
}
|
|
private function determineBucketRegionFromExceptionBody(ResponseInterface $response)
|
|
{
|
|
try {
|
|
$element = $this->parseXml($response->getBody(), $response);
|
|
if (!empty($element->Region)) {
|
|
return (string) $element->Region;
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Fallthrough on exceptions from parsing
|
|
}
|
|
return \false;
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::doesBucketExist()
|
|
*/
|
|
public function doesBucketExist($bucket)
|
|
{
|
|
return $this->checkExistenceWithCommand($this->getCommand('HeadBucket', ['Bucket' => $bucket]));
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::doesBucketExistV2()
|
|
*/
|
|
public function doesBucketExistV2($bucket, $accept403 = \false)
|
|
{
|
|
$command = $this->getCommand('HeadBucket', ['Bucket' => $bucket]);
|
|
try {
|
|
$this->execute($command);
|
|
return \true;
|
|
} catch (S3Exception $e) {
|
|
if ($accept403 && $e->getStatusCode() === 403 || $e instanceof PermanentRedirectException) {
|
|
return \true;
|
|
}
|
|
if ($e->getStatusCode() === 404) {
|
|
return \false;
|
|
}
|
|
throw $e;
|
|
}
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::doesObjectExist()
|
|
*/
|
|
public function doesObjectExist($bucket, $key, array $options = [])
|
|
{
|
|
return $this->checkExistenceWithCommand($this->getCommand('HeadObject', ['Bucket' => $bucket, 'Key' => $key] + $options));
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::doesObjectExistV2()
|
|
*/
|
|
public function doesObjectExistV2($bucket, $key, $includeDeleteMarkers = \false, array $options = [])
|
|
{
|
|
$command = $this->getCommand('HeadObject', ['Bucket' => $bucket, 'Key' => $key] + $options);
|
|
try {
|
|
$this->execute($command);
|
|
return \true;
|
|
} catch (S3Exception $e) {
|
|
if ($includeDeleteMarkers && $this->useDeleteMarkers($e)) {
|
|
return \true;
|
|
}
|
|
if ($e->getStatusCode() === 404) {
|
|
return \false;
|
|
}
|
|
throw $e;
|
|
}
|
|
}
|
|
private function useDeleteMarkers($exception)
|
|
{
|
|
$response = $exception->getResponse();
|
|
return !empty($response) && $response->getHeader('x-amz-delete-marker');
|
|
}
|
|
/**
|
|
* Determines whether or not a resource exists using a command
|
|
*
|
|
* @param CommandInterface $command Command used to poll for the resource
|
|
*
|
|
* @return bool
|
|
* @throws S3Exception|\Exception if there is an unhandled exception
|
|
*/
|
|
private function checkExistenceWithCommand(CommandInterface $command)
|
|
{
|
|
try {
|
|
$this->execute($command);
|
|
return \true;
|
|
} catch (S3Exception $e) {
|
|
if ($e->getAwsErrorCode() == 'AccessDenied') {
|
|
return \true;
|
|
}
|
|
if ($e->getStatusCode() >= 500) {
|
|
throw $e;
|
|
}
|
|
return \false;
|
|
}
|
|
}
|
|
/**
|
|
* @see S3ClientInterface::execute()
|
|
*/
|
|
public abstract function execute(CommandInterface $command);
|
|
/**
|
|
* @see S3ClientInterface::getCommand()
|
|
*/
|
|
public abstract function getCommand($name, array $args = []);
|
|
/**
|
|
* @see S3ClientInterface::getHandlerList()
|
|
*
|
|
* @return HandlerList
|
|
*/
|
|
public abstract function getHandlerList();
|
|
/**
|
|
* @see S3ClientInterface::getIterator()
|
|
*
|
|
* @return \Iterator
|
|
*/
|
|
public abstract function getIterator($name, array $args = []);
|
|
}
|