diff --git a/CHANGELOG.md b/CHANGELOG.md index edba47e..5348ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Release Notes for Amazon S3 for Craft CMS +## Unreleased + +- Added a per-filesystem endpoint override for S3-compatible storage services, without changing the default Amazon S3 behavior. +- Added a per-filesystem option to control whether configured access keys should be exchanged for STS temporary credentials. + ## 2.3.0 - 2026-02-24 - The AWS S3 plugin now requires PHP 8.1.0 or later. diff --git a/README.md b/README.md index f727926..a92239e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ composer require craftcms/aws-s3 To create a new Amazon S3 filesystem to use with your volumes, visit **Settings** → **Filesystems**, and press **New filesystem**. Select “Amazon S3” for the **Filesystem Type** setting and configure as needed. -> 💡 The Base URL, Access Key ID, Secret Access Key, Bucket, Region, Subfolder, CloudFront Distribution ID, and CloudFront Path Prefix settings can be set to environment variables. See [Environmental Configuration](https://craftcms.com/docs/4.x/config/#environmental-configuration) in the Craft docs to learn more about that. +> 💡 The Base URL, Access Key ID, Secret Access Key, Bucket, Region, Endpoint URL, Subfolder, CloudFront Distribution ID, and CloudFront Path Prefix settings can be set to environment variables. See [Environmental Configuration](https://craftcms.com/docs/4.x/config/#environmental-configuration) in the Craft docs to learn more about that. ### AWS IAM Permissions @@ -102,6 +102,14 @@ A typical IAM policy that grants the user to choose a bucket can look like this: } ``` +### S3-compatible storage + +If you need to connect to an S3-compatible service, provide the service’s **Endpoint URL**. + +When an endpoint URL is configured, the plugin will connect directly to that endpoint. Leave the endpoint URL empty to preserve the plugin’s standard AWS behavior. + +By default, the plugin uses AWS STS temporary credentials when an access key and secret are configured. You can disable **Use STS temporary credentials?** to use the configured credentials directly instead, which may be required for some S3-compatible providers. + ### Using automatic focal point detection This plugin can use the [AWS Rekognition](https://aws.amazon.com/rekognition/) service to detect faces in an image and automatically set the focal point accordingly. This requires the image to be either a jpg or a png file. You can enable this feature via **Attempt to set the focal point automatically?** in the filesystem settings. diff --git a/src/Fs.php b/src/Fs.php index bc5ae8d..75b029b 100644 --- a/src/Fs.php +++ b/src/Fs.php @@ -16,6 +16,7 @@ use Aws\Handler\Guzzle\GuzzleHandler; use Aws\Rekognition\RekognitionClient; use Aws\S3\Exception\S3Exception; +use Aws\S3\S3Client as AwsS3Client; use Aws\Sts\StsClient; use Craft; use craft\behaviors\EnvAttributeParserBehavior; @@ -104,6 +105,16 @@ public static function displayName(): string */ public string $region = ''; + /** + * @var string Custom S3 endpoint to use for compatible services + */ + public string $endpoint = ''; + + /** + * @var bool Whether STS temporary credentials should be used + */ + public bool|string $useSts = true; + /** * @var string Cache expiration period. */ @@ -178,6 +189,7 @@ public function behaviors(): array 'secret', 'bucket', 'region', + 'endpoint', 'subfolder', 'cfDistributionId', 'cfPrefix', @@ -215,10 +227,15 @@ public function getSettingsHtml(): ?string * @return array * @throws InvalidArgumentException */ - public static function loadBucketList(?string $keyId, ?string $secret): array - { + public static function loadBucketList( + ?string $keyId, + ?string $secret, + ?string $region = null, + ?string $endpoint = null, + bool $useSts = false, + ): array { // Any region will do. - $config = self::buildConfigArray($keyId, $secret, 'us-east-1'); + $config = self::buildConfigArray($keyId, $secret, $region ?? 'us-east-1', false, $endpoint, $useSts); $client = static::client($config); @@ -232,18 +249,25 @@ public static function loadBucketList(?string $keyId, ?string $secret): array $bucketList = []; foreach ($buckets as $bucket) { - try { - $region = $client->determineBucketRegion($bucket['Name']); - } catch (S3Exception $exception) { + $urlPrefix = ''; - // If a bucket cannot be accessed by the current policy, move along: - // https://github.com/craftcms/aws-s3/pull/29#issuecomment-468193410 - continue; + if ($endpoint) { + $region = $region ?? 'us-east-1'; + $urlPrefix = rtrim((string)$endpoint, '/') . '/' . $bucket['Name'] . '/'; + } else { + try { + $region = $client->determineBucketRegion($bucket['Name']); + } catch (S3Exception $exception) { + + // If a bucket cannot be accessed by the current policy, move along: + // https://github.com/craftcms/aws-s3/pull/29#issuecomment-468193410 + continue; + } } - if (str_contains($bucket['Name'], '.')) { + if (!$endpoint && str_contains($bucket['Name'], '.')) { $urlPrefix = 'https://s3.' . $region . '.amazonaws.com/' . $bucket['Name'] . '/'; - } else { + } elseif (!$endpoint) { $urlPrefix = 'https://' . $bucket['Name'] . '.s3.amazonaws.com/'; } @@ -280,7 +304,7 @@ public function getRootUrl(): ?string */ protected function createAdapter(): FilesystemAdapter { - $client = static::client($this->_getConfigArray(), $this->_getCredentials()); + $client = static::client($this->_getS3ConfigArray(), $this->_getCredentials()); $options = [ // This is the S3 default for all objects, but explicitly @@ -307,9 +331,13 @@ protected function createAdapter(): FilesystemAdapter * @param array $credentials credentials to use when generating a new token * @return S3Client */ - protected static function client(array $config = [], array $credentials = []): S3Client + protected static function client(array $config = [], array $credentials = []): AwsS3Client { - if (!empty($config['credentials']) && $config['credentials'] instanceof Credentials) { + if ( + !empty($config['refreshableCredentials']) && + !empty($config['credentials']) && + $config['credentials'] instanceof Credentials + ) { $config['generateNewConfig'] = static function() use ($credentials) { $args = [ $credentials['keyId'], @@ -321,6 +349,8 @@ protected static function client(array $config = [], array $credentials = []): S }; } + unset($config['refreshableCredentials']); + return new S3Client($config); } @@ -410,7 +440,7 @@ public function detectFocalPoint(string $filePath): array } - $client = new RekognitionClient($this->_getConfigArray()); + $client = new RekognitionClient($this->_getAwsConfigArray()); $params = [ 'Image' => [ 'S3Object' => [ @@ -443,10 +473,18 @@ public function detectFocalPoint(string $filePath): array * @param ?string $secret The key secret * @param ?string $region The region to user * @param bool $refreshToken If true will always refresh token + * @param ?string $endpoint The custom S3 endpoint + * @param bool $useSts Whether STS temporary credentials should be used * @return array */ - public static function buildConfigArray(?string $keyId = null, ?string $secret = null, ?string $region = null, bool $refreshToken = false): array - { + public static function buildConfigArray( + ?string $keyId = null, + ?string $secret = null, + ?string $region = null, + bool $refreshToken = false, + ?string $endpoint = null, + bool $useSts = false, + ): array { $config = [ 'region' => $region, 'version' => 'latest', @@ -455,6 +493,20 @@ public static function buildConfigArray(?string $keyId = null, ?string $secret = $client = Craft::createGuzzleClient(); $config['http_handler'] = new GuzzleHandler($client); + $endpoint = $endpoint ? rtrim($endpoint, '/') : null; + + if ($endpoint) { + $config['endpoint'] = $endpoint; + } + + if (!$useSts) { + if (!empty($keyId) && !empty($secret)) { + $config['credentials'] = new Credentials($keyId, $secret); + } + + return $config; + } + /** @noinspection MissingOrEmptyGroupStatementInspection */ if (empty($keyId) || empty($secret)) { // Check for predefined access @@ -491,6 +543,7 @@ public static function buildConfigArray(?string $keyId = null, ?string $secret = // TODO Add support for different credential supply methods $config['credentials'] = $credentials; + $config['refreshableCredentials'] = true; } return $config; @@ -547,19 +600,45 @@ private function _cfPrefix(): string */ private function _getCloudFrontClient(): CloudFrontClient { - return new CloudFrontClient($this->_getConfigArray()); + return new CloudFrontClient($this->_getAwsConfigArray()); + } + + /** + * Get the config array for S3 clients. + * + * @return array + */ + private function _getS3ConfigArray(): array + { + $credentials = $this->_getCredentials(); + + return self::buildConfigArray( + $credentials['keyId'], + $credentials['secret'], + $credentials['region'], + false, + Craft::parseEnv($this->endpoint), + App::parseBooleanEnv($this->useSts) ?? true, + ); } /** - * Get the config array for AWS Clients. + * Get the config array for AWS clients. * * @return array */ - private function _getConfigArray(): array + private function _getAwsConfigArray(): array { $credentials = $this->_getCredentials(); - return self::buildConfigArray($credentials['keyId'], $credentials['secret'], $credentials['region']); + return self::buildConfigArray( + $credentials['keyId'], + $credentials['secret'], + $credentials['region'], + false, + null, + App::parseBooleanEnv($this->useSts) ?? true, + ); } /** diff --git a/src/controllers/BucketsController.php b/src/controllers/BucketsController.php index 0602359..a04c74c 100644 --- a/src/controllers/BucketsController.php +++ b/src/controllers/BucketsController.php @@ -30,12 +30,15 @@ public function actionLoadBucketData(): Response $this->requireAcceptsJson(); $request = Craft::$app->getRequest(); - $keyId = App::parseEnv($request->getRequiredBodyParam('keyId')); - $secret = App::parseEnv($request->getRequiredBodyParam('secret')); + $keyId = App::parseEnv($request->getBodyParam('keyId')); + $secret = App::parseEnv($request->getBodyParam('secret')); + $region = App::parseEnv($request->getBodyParam('region')); + $endpoint = App::parseEnv($request->getBodyParam('endpoint')); + $useSts = $request->getBodyParam('useSts', false); try { return $this->asJson([ - 'buckets' => Fs::loadBucketList($keyId, $secret), + 'buckets' => Fs::loadBucketList($keyId, $secret, $region, $endpoint, filter_var($useSts, FILTER_VALIDATE_BOOL)), ]); } catch (\Throwable $e) { return $this->asFailure($e->getMessage()); diff --git a/src/resources/js/editVolume.js b/src/resources/js/editVolume.js index 59dcccf..07b40ef 100644 --- a/src/resources/js/editVolume.js +++ b/src/resources/js/editVolume.js @@ -1,6 +1,8 @@ $(document).ready(function () { const $s3AccessKeyIdInput = $('.s3-key-id'); const $s3SecretAccessKeyInput = $('.s3-secret-key'); + const $s3EndpointInput = $('.s3-endpoint'); + const $s3UseStsInput = $('.s3-use-sts'); const $s3BucketSelect = $('.s3-bucket-select > select'); const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn @@ -25,6 +27,9 @@ $(document).ready(function () { const data = { keyId: $s3AccessKeyIdInput.val(), secret: $s3SecretAccessKeyInput.val(), + region: $s3Region.val(), + endpoint: $s3EndpointInput.val(), + useSts: $s3UseStsInput.prop('checked'), }; Craft.sendActionRequest('POST', 'aws-s3/buckets/load-bucket-data', {data}) @@ -32,7 +37,7 @@ $(document).ready(function () { if (!data.buckets.length) { return; } - // + const currentBucket = $s3BucketSelect.val(); let currentBucketStillExists = false; @@ -41,7 +46,7 @@ $(document).ready(function () { $s3BucketSelect.prop('readonly', false).empty(); for (let i = 0; i < data.buckets.length; i++) { - if (data.buckets[i].bucket == currentBucket) { + if (data.buckets[i].bucket === currentBucket) { currentBucketStillExists = true; } @@ -107,6 +112,10 @@ $(document).ready(function () { $('.s3-expires-period select').change(s3ChangeExpiryValue); const maybeUpdateUrl = function () { + if ($s3EndpointInput.val().length) { + return; + } + if ( $hasUrls.val() && $manualBucket.val().length && @@ -124,4 +133,5 @@ $(document).ready(function () { $manualRegion.keyup(maybeUpdateUrl); $manualBucket.keyup(maybeUpdateUrl); + $s3EndpointInput.keyup(maybeUpdateUrl).change(maybeUpdateUrl); }); diff --git a/src/templates/fsSettings.html b/src/templates/fsSettings.html index 9143eae..f39c0bc 100644 --- a/src/templates/fsSettings.html +++ b/src/templates/fsSettings.html @@ -22,6 +22,27 @@ instructions: 'You can leave this field empty if you are using an EC2 instance with an applicable IAM role assignment.'|t('aws-s3') }) }} +{{ forms.autosuggestField({ + label: "Endpoint URL"|t('aws-s3'), + instructions: 'Provide the S3 API endpoint for your compatible storage service. Leave this empty for Amazon S3.'|t('aws-s3'), + id: 'endpoint', + name: 'endpoint', + suggestEnvVars: true, + value: fs.endpoint, + errors: fs.getErrors('endpoint'), + class: 'ltr s3-endpoint' +}) }} + +{{ forms.booleanMenuField({ + label: "Use STS temporary credentials?"|t('aws-s3'), + instructions: 'Turn this on to exchange the configured access key and secret for temporary STS credentials. Turn it off to use the configured credentials directly.'|t('aws-s3'), + id: 'useSts', + name: 'useSts', + includeEnvVars: true, + value: fs.useSts, + class: 'ltr s3-use-sts', +}) }} + {% set bucketInput %}