From ee64a64b117b57d331ae4212a9e4fabdc6f0127c Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 21 Apr 2026 07:39:25 -0400 Subject: [PATCH 1/6] Add per-filesystem S3-compatible endpoint support --- CHANGELOG.md | 4 + README.md | 8 +- src/Fs.php | 118 +++++++++++--- src/controllers/BucketsController.php | 9 +- src/resources/js/editVolume.js | 222 +++++++++++++------------- src/templates/fsSettings.html | 27 ++++ 6 files changed, 248 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edba47e..503c448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes for Amazon S3 for Craft CMS +## Unreleased + +- Added a per-filesystem connection type and endpoint override for MinIO and other S3-compatible storage services, without changing the default Amazon S3 behavior. + ## 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..8c8e2ac 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,12 @@ 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 a MinIO or other S3-compatible service, set **Connection Type** to **S3-compatible storage** and provide the service’s **Endpoint URL**. + +In that mode, the plugin will connect directly to the configured endpoint using the provided access key and secret, without requesting temporary credentials from AWS STS. Leave the connection type set to **Amazon S3** to preserve the plugin’s standard AWS behavior. + ### 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..6d76d8d 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 Auth mode to use for this filesystem + */ + public string $authMode = 'aws'; + + /** + * @var string Custom S3 endpoint to use for compatible services + */ + public string $endpoint = ''; + /** * @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 $authMode = 'aws', + ?string $endpoint = null, + ): array { // Any region will do. - $config = self::buildConfigArray($keyId, $secret, 'us-east-1'); + $config = self::buildConfigArray($keyId, $secret, $region ?? 'us-east-1', false, $authMode, $endpoint); $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 ($authMode === 'compatible') { + $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 ($authMode !== 'compatible' && str_contains($bucket['Name'], '.')) { $urlPrefix = 'https://s3.' . $region . '.amazonaws.com/' . $bucket['Name'] . '/'; - } else { + } elseif ($authMode !== 'compatible') { $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 $authMode The auth mode + * @param ?string $endpoint The custom S3 endpoint * @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 $authMode = 'aws', + ?string $endpoint = null, + ): 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 ($authMode === 'compatible') { + if ($endpoint) { + $config['endpoint'] = $endpoint; + } + + 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,42 @@ private function _cfPrefix(): string */ private function _getCloudFrontClient(): CloudFrontClient { - return new CloudFrontClient($this->_getConfigArray()); + return new CloudFrontClient($this->_getAwsConfigArray()); } /** - * Get the config array for AWS Clients. + * Get the config array for S3 clients. * * @return array */ - private function _getConfigArray(): array + private function _getS3ConfigArray(): array { $credentials = $this->_getCredentials(); - return self::buildConfigArray($credentials['keyId'], $credentials['secret'], $credentials['region']); + return self::buildConfigArray( + $credentials['keyId'], + $credentials['secret'], + $credentials['region'], + false, + $this->authMode, + Craft::parseEnv($this->endpoint), + ); + } + + /** + * Get the config array for AWS clients. + * + * @return array + */ + private function _getAwsConfigArray(): array + { + $credentials = $this->_getCredentials(); + + return self::buildConfigArray( + $credentials['keyId'], + $credentials['secret'], + $credentials['region'], + ); } /** diff --git a/src/controllers/BucketsController.php b/src/controllers/BucketsController.php index 0602359..7145c96 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')); + $authMode = $request->getBodyParam('authMode', 'aws'); + $endpoint = App::parseEnv($request->getBodyParam('endpoint')); try { return $this->asJson([ - 'buckets' => Fs::loadBucketList($keyId, $secret), + 'buckets' => Fs::loadBucketList($keyId, $secret, $region, $authMode, $endpoint), ]); } catch (\Throwable $e) { return $this->asFailure($e->getMessage()); diff --git a/src/resources/js/editVolume.js b/src/resources/js/editVolume.js index 59dcccf..944a1e1 100644 --- a/src/resources/js/editVolume.js +++ b/src/resources/js/editVolume.js @@ -1,127 +1,119 @@ $(document).ready(function () { - const $s3AccessKeyIdInput = $('.s3-key-id'); - const $s3SecretAccessKeyInput = $('.s3-secret-key'); - const $s3BucketSelect = $('.s3-bucket-select > select'); - const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); - const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn - .parent() - .next() - .children(); - const $s3Region = $('.s3-region'); - const $manualBucket = $('.s3-manualBucket'); - const $manualRegion = $('.s3-manualRegion'); - const $fsUrl = $('.fs-url'); - const $hasUrls = $('input[name=hasUrls]'); - let refreshingS3Buckets = false; - - $s3RefreshBucketsBtn.click(function () { - if ($s3RefreshBucketsBtn.hasClass('disabled')) { - return; - } + const $s3AccessKeyIdInput = $('.s3-key-id'); + const $s3SecretAccessKeyInput = $('.s3-secret-key'); + const $s3AuthModeInput = $('#authMode'); + const $s3EndpointInput = $('.s3-endpoint'); + const $s3BucketSelect = $('.s3-bucket-select > select'); + const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); + const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn.parent().next().children(); + const $s3Region = $('.s3-region'); + const $manualBucket = $('.s3-manualBucket'); + const $manualRegion = $('.s3-manualRegion'); + const $fsUrl = $('.fs-url'); + const $hasUrls = $('input[name=hasUrls]'); + let refreshingS3Buckets = false; + + $s3RefreshBucketsBtn.click(function () { + if ($s3RefreshBucketsBtn.hasClass('disabled')) { + return; + } - $s3RefreshBucketsBtn.addClass('disabled'); - $s3RefreshBucketsSpinner.removeClass('hidden'); + $s3RefreshBucketsBtn.addClass('disabled'); + $s3RefreshBucketsSpinner.removeClass('hidden'); + + const data = { + keyId: $s3AccessKeyIdInput.val(), + secret: $s3SecretAccessKeyInput.val(), + region: $s3Region.val(), + authMode: $s3AuthModeInput.val(), + endpoint: $s3EndpointInput.val(), + }; + + Craft.sendActionRequest('POST', 'aws-s3/buckets/load-bucket-data', { data }) + .then(({ data }) => { + if (!data.buckets.length) { + return; + } + + const currentBucket = $s3BucketSelect.val(); + let currentBucketStillExists = false; + + refreshingS3Buckets = true; + + $s3BucketSelect.prop('readonly', false).empty(); + + for (let i = 0; i < data.buckets.length; i++) { + if (data.buckets[i].bucket === currentBucket) { + currentBucketStillExists = true; + } + + $s3BucketSelect.append( + '' + ); + } + + if (currentBucketStillExists) { + $s3BucketSelect.val(currentBucket); + } + + refreshingS3Buckets = false; + + if (!currentBucketStillExists) { + $s3BucketSelect.trigger('change'); + } + }) + .catch(({ response }) => { + alert(response.data.message); + }) + .finally(() => { + $s3RefreshBucketsBtn.removeClass('disabled'); + $s3RefreshBucketsSpinner.addClass('hidden'); + }); + }); + + $s3BucketSelect.change(function () { + if (refreshingS3Buckets) { + return; + } - const data = { - keyId: $s3AccessKeyIdInput.val(), - secret: $s3SecretAccessKeyInput.val(), - }; + const $selectedOption = $s3BucketSelect.children('option:selected'); - Craft.sendActionRequest('POST', 'aws-s3/buckets/load-bucket-data', {data}) - .then(({data}) => { - if (!data.buckets.length) { - return; - } - // - const currentBucket = $s3BucketSelect.val(); - let currentBucketStillExists = false; - - refreshingS3Buckets = true; - - $s3BucketSelect.prop('readonly', false).empty(); - - for (let i = 0; i < data.buckets.length; i++) { - if (data.buckets[i].bucket == currentBucket) { - currentBucketStillExists = true; - } - - $s3BucketSelect.append( - '' - ); - } + $fsUrl.val($selectedOption.data('url-prefix')); + $s3Region.val($selectedOption.data('region')); + }); - if (currentBucketStillExists) { - $s3BucketSelect.val(currentBucket); - } + function s3ChangeExpiryValue() { + const parent = $(this).parents('.field'); + const amount = parent.find('.s3-expires-amount').val(); + const period = parent.find('.s3-expires-period select').val(); - refreshingS3Buckets = false; + const combinedValue = parseInt(amount, 10) === 0 || period.length === 0 ? '' : amount + ' ' + period; - if (!currentBucketStillExists) { - $s3BucketSelect.trigger('change'); - } - }) - .catch(({response}) => { - alert(response.data.message); - }) - .finally(() => { - $s3RefreshBucketsBtn.removeClass('disabled'); - $s3RefreshBucketsSpinner.addClass('hidden'); - }); - }); - - $s3BucketSelect.change(function () { - if (refreshingS3Buckets) { - return; + parent.find('[type=hidden]').val(combinedValue); } - const $selectedOption = $s3BucketSelect.children('option:selected'); - - $fsUrl.val($selectedOption.data('url-prefix')); - $s3Region.val($selectedOption.data('region')); - }); - - const s3ChangeExpiryValue = function () { - const parent = $(this).parents('.field'); - const amount = parent.find('.s3-expires-amount').val(); - const period = parent.find('.s3-expires-period select').val(); - - const combinedValue = - parseInt(amount, 10) === 0 || period.length === 0 - ? '' - : amount + ' ' + period; - - parent.find('[type=hidden]').val(combinedValue); - }; - - $('.s3-expires-amount') - .keyup(s3ChangeExpiryValue) - .change(s3ChangeExpiryValue); - $('.s3-expires-period select').change(s3ChangeExpiryValue); - - const maybeUpdateUrl = function () { - if ( - $hasUrls.val() && - $manualBucket.val().length && - $manualRegion.val().length - ) { - $fsUrl.val( - 'https://s3.' + - $manualRegion.val() + - '.amazonaws.com/' + - $manualBucket.val() + - '/' - ); + $('.s3-expires-amount').keyup(s3ChangeExpiryValue).change(s3ChangeExpiryValue); + $('.s3-expires-period select').change(s3ChangeExpiryValue); + + function maybeUpdateUrl() { + if ($s3AuthModeInput.val() === 'compatible') { + return; + } + + if ($hasUrls.val() && $manualBucket.val().length && $manualRegion.val().length) { + $fsUrl.val(`https://s3.${$manualRegion.val()}.amazonaws.com/${$manualBucket.val()}/`); + } } - }; - $manualRegion.keyup(maybeUpdateUrl); - $manualBucket.keyup(maybeUpdateUrl); + $manualRegion.keyup(maybeUpdateUrl); + $manualBucket.keyup(maybeUpdateUrl); + $s3AuthModeInput.change(maybeUpdateUrl); }); diff --git a/src/templates/fsSettings.html b/src/templates/fsSettings.html index 9143eae..2cb2628 100644 --- a/src/templates/fsSettings.html +++ b/src/templates/fsSettings.html @@ -22,6 +22,33 @@ instructions: 'You can leave this field empty if you are using an EC2 instance with an applicable IAM role assignment.'|t('aws-s3') }) }} +{{ forms.selectField({ + label: "Connection Type"|t('aws-s3'), + instructions: 'Use Amazon S3 to preserve the plugin’s standard AWS behavior. Use S3-compatible storage to connect directly to services like MinIO using a custom endpoint.'|t('aws-s3'), + id: 'authMode', + name: 'authMode', + options: [ + { label: 'Amazon S3'|t('aws-s3'), value: 'aws' }, + { label: 'S3-compatible storage'|t('aws-s3'), value: 'compatible' }, + ], + value: fs.authMode, + toggle: true, + targetPrefix: '.auth-mode-' +}) }} + +
+ {{ forms.autosuggestField({ + label: "Endpoint URL"|t('aws-s3'), + instructions: 'Provide the S3 API endpoint for your compatible storage service, such as MinIO. 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' + }) }} +
+ {% set bucketInput %}
{{ forms.select({ From 3c2975e53ed56ee28f8ac60904a1d34e6b03d9a7 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 21 Apr 2026 09:03:14 -0400 Subject: [PATCH 2/6] Add conditional endpoint for s3-compatible --- README.md | 4 ++-- src/Fs.php | 23 +++++------------- src/controllers/BucketsController.php | 3 +-- src/resources/js/editVolume.js | 6 ++--- src/templates/fsSettings.html | 34 +++++++-------------------- 5 files changed, 20 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 8c8e2ac..0c12246 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ 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 a MinIO or other S3-compatible service, set **Connection Type** to **S3-compatible storage** and provide the service’s **Endpoint URL**. +If you need to connect to a MinIO or other S3-compatible service, provide the service’s **Endpoint URL**. -In that mode, the plugin will connect directly to the configured endpoint using the provided access key and secret, without requesting temporary credentials from AWS STS. Leave the connection type set to **Amazon S3** to preserve the plugin’s standard AWS behavior. +When an endpoint URL is configured, the plugin will connect directly to that endpoint using the provided access key and secret, without requesting temporary credentials from AWS STS. Leave the endpoint URL empty to preserve the plugin’s standard AWS behavior. ### Using automatic focal point detection diff --git a/src/Fs.php b/src/Fs.php index 6d76d8d..cd71c88 100644 --- a/src/Fs.php +++ b/src/Fs.php @@ -105,11 +105,6 @@ public static function displayName(): string */ public string $region = ''; - /** - * @var string Auth mode to use for this filesystem - */ - public string $authMode = 'aws'; - /** * @var string Custom S3 endpoint to use for compatible services */ @@ -231,11 +226,10 @@ public static function loadBucketList( ?string $keyId, ?string $secret, ?string $region = null, - string $authMode = 'aws', ?string $endpoint = null, ): array { // Any region will do. - $config = self::buildConfigArray($keyId, $secret, $region ?? 'us-east-1', false, $authMode, $endpoint); + $config = self::buildConfigArray($keyId, $secret, $region ?? 'us-east-1', false, $endpoint); $client = static::client($config); @@ -251,7 +245,7 @@ public static function loadBucketList( foreach ($buckets as $bucket) { $urlPrefix = ''; - if ($authMode === 'compatible') { + if ($endpoint) { $region = $region ?? 'us-east-1'; $urlPrefix = rtrim((string)$endpoint, '/') . '/' . $bucket['Name'] . '/'; } else { @@ -265,9 +259,9 @@ public static function loadBucketList( } } - if ($authMode !== 'compatible' && str_contains($bucket['Name'], '.')) { + if (!$endpoint && str_contains($bucket['Name'], '.')) { $urlPrefix = 'https://s3.' . $region . '.amazonaws.com/' . $bucket['Name'] . '/'; - } elseif ($authMode !== 'compatible') { + } elseif (!$endpoint) { $urlPrefix = 'https://' . $bucket['Name'] . '.s3.amazonaws.com/'; } @@ -473,7 +467,6 @@ 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 $authMode The auth mode * @param ?string $endpoint The custom S3 endpoint * @return array */ @@ -482,7 +475,6 @@ public static function buildConfigArray( ?string $secret = null, ?string $region = null, bool $refreshToken = false, - string $authMode = 'aws', ?string $endpoint = null, ): array { $config = [ @@ -495,10 +487,8 @@ public static function buildConfigArray( $endpoint = $endpoint ? rtrim($endpoint, '/') : null; - if ($authMode === 'compatible') { - if ($endpoint) { - $config['endpoint'] = $endpoint; - } + if ($endpoint) { + $config['endpoint'] = $endpoint; if (!empty($keyId) && !empty($secret)) { $config['credentials'] = new Credentials($keyId, $secret); @@ -617,7 +607,6 @@ private function _getS3ConfigArray(): array $credentials['secret'], $credentials['region'], false, - $this->authMode, Craft::parseEnv($this->endpoint), ); } diff --git a/src/controllers/BucketsController.php b/src/controllers/BucketsController.php index 7145c96..61aa497 100644 --- a/src/controllers/BucketsController.php +++ b/src/controllers/BucketsController.php @@ -33,12 +33,11 @@ public function actionLoadBucketData(): Response $keyId = App::parseEnv($request->getBodyParam('keyId')); $secret = App::parseEnv($request->getBodyParam('secret')); $region = App::parseEnv($request->getBodyParam('region')); - $authMode = $request->getBodyParam('authMode', 'aws'); $endpoint = App::parseEnv($request->getBodyParam('endpoint')); try { return $this->asJson([ - 'buckets' => Fs::loadBucketList($keyId, $secret, $region, $authMode, $endpoint), + 'buckets' => Fs::loadBucketList($keyId, $secret, $region, $endpoint), ]); } catch (\Throwable $e) { return $this->asFailure($e->getMessage()); diff --git a/src/resources/js/editVolume.js b/src/resources/js/editVolume.js index 944a1e1..ac2d11e 100644 --- a/src/resources/js/editVolume.js +++ b/src/resources/js/editVolume.js @@ -1,7 +1,6 @@ $(document).ready(function () { const $s3AccessKeyIdInput = $('.s3-key-id'); const $s3SecretAccessKeyInput = $('.s3-secret-key'); - const $s3AuthModeInput = $('#authMode'); const $s3EndpointInput = $('.s3-endpoint'); const $s3BucketSelect = $('.s3-bucket-select > select'); const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); @@ -25,7 +24,6 @@ $(document).ready(function () { keyId: $s3AccessKeyIdInput.val(), secret: $s3SecretAccessKeyInput.val(), region: $s3Region.val(), - authMode: $s3AuthModeInput.val(), endpoint: $s3EndpointInput.val(), }; @@ -104,7 +102,7 @@ $(document).ready(function () { $('.s3-expires-period select').change(s3ChangeExpiryValue); function maybeUpdateUrl() { - if ($s3AuthModeInput.val() === 'compatible') { + if ($s3EndpointInput.val().length) { return; } @@ -115,5 +113,5 @@ $(document).ready(function () { $manualRegion.keyup(maybeUpdateUrl); $manualBucket.keyup(maybeUpdateUrl); - $s3AuthModeInput.change(maybeUpdateUrl); + $s3EndpointInput.keyup(maybeUpdateUrl).change(maybeUpdateUrl); }); diff --git a/src/templates/fsSettings.html b/src/templates/fsSettings.html index 2cb2628..f0fbda9 100644 --- a/src/templates/fsSettings.html +++ b/src/templates/fsSettings.html @@ -22,33 +22,17 @@ instructions: 'You can leave this field empty if you are using an EC2 instance with an applicable IAM role assignment.'|t('aws-s3') }) }} -{{ forms.selectField({ - label: "Connection Type"|t('aws-s3'), - instructions: 'Use Amazon S3 to preserve the plugin’s standard AWS behavior. Use S3-compatible storage to connect directly to services like MinIO using a custom endpoint.'|t('aws-s3'), - id: 'authMode', - name: 'authMode', - options: [ - { label: 'Amazon S3'|t('aws-s3'), value: 'aws' }, - { label: 'S3-compatible storage'|t('aws-s3'), value: 'compatible' }, - ], - value: fs.authMode, - toggle: true, - targetPrefix: '.auth-mode-' +{{ forms.autosuggestField({ + label: "Endpoint URL"|t('aws-s3'), + instructions: 'Provide the S3 API endpoint for your compatible storage service, such as MinIO. 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.autosuggestField({ - label: "Endpoint URL"|t('aws-s3'), - instructions: 'Provide the S3 API endpoint for your compatible storage service, such as MinIO. 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' - }) }} -
- {% set bucketInput %}
{{ forms.select({ From 1e2bcb48b2344f53cc87c16bb4777f86a1a54416 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 21 Apr 2026 09:21:24 -0400 Subject: [PATCH 3/6] Reduce JS whitespace in endpoint changes --- src/resources/js/editVolume.js | 222 ++++++++++++++++++--------------- 1 file changed, 120 insertions(+), 102 deletions(-) diff --git a/src/resources/js/editVolume.js b/src/resources/js/editVolume.js index ac2d11e..6beca1f 100644 --- a/src/resources/js/editVolume.js +++ b/src/resources/js/editVolume.js @@ -1,117 +1,135 @@ $(document).ready(function () { - const $s3AccessKeyIdInput = $('.s3-key-id'); - const $s3SecretAccessKeyInput = $('.s3-secret-key'); - const $s3EndpointInput = $('.s3-endpoint'); - const $s3BucketSelect = $('.s3-bucket-select > select'); - const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); - const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn.parent().next().children(); - const $s3Region = $('.s3-region'); - const $manualBucket = $('.s3-manualBucket'); - const $manualRegion = $('.s3-manualRegion'); - const $fsUrl = $('.fs-url'); - const $hasUrls = $('input[name=hasUrls]'); - let refreshingS3Buckets = false; - - $s3RefreshBucketsBtn.click(function () { - if ($s3RefreshBucketsBtn.hasClass('disabled')) { - return; + const $s3AccessKeyIdInput = $('.s3-key-id'); + const $s3SecretAccessKeyInput = $('.s3-secret-key'); + const $s3EndpointInput = $('.s3-endpoint'); + const $s3BucketSelect = $('.s3-bucket-select > select'); + const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); + const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn + .parent() + .next() + .children(); + const $s3Region = $('.s3-region'); + const $manualBucket = $('.s3-manualBucket'); + const $manualRegion = $('.s3-manualRegion'); + const $fsUrl = $('.fs-url'); + const $hasUrls = $('input[name=hasUrls]'); + let refreshingS3Buckets = false; + + $s3RefreshBucketsBtn.click(function () { + if ($s3RefreshBucketsBtn.hasClass('disabled')) { + return; + } + + $s3RefreshBucketsBtn.addClass('disabled'); + $s3RefreshBucketsSpinner.removeClass('hidden'); + + const data = { + keyId: $s3AccessKeyIdInput.val(), + secret: $s3SecretAccessKeyInput.val(), + region: $s3Region.val(), + endpoint: $s3EndpointInput.val(), + }; + + Craft.sendActionRequest('POST', 'aws-s3/buckets/load-bucket-data', {data}) + .then(({data}) => { + if (!data.buckets.length) { + return; + } + + const currentBucket = $s3BucketSelect.val(); + let currentBucketStillExists = false; + + refreshingS3Buckets = true; + + $s3BucketSelect.prop('readonly', false).empty(); + + for (let i = 0; i < data.buckets.length; i++) { + if (data.buckets[i].bucket === currentBucket) { + currentBucketStillExists = true; + } + + $s3BucketSelect.append( + '' + ); + } + + if (currentBucketStillExists) { + $s3BucketSelect.val(currentBucket); } - $s3RefreshBucketsBtn.addClass('disabled'); - $s3RefreshBucketsSpinner.removeClass('hidden'); - - const data = { - keyId: $s3AccessKeyIdInput.val(), - secret: $s3SecretAccessKeyInput.val(), - region: $s3Region.val(), - endpoint: $s3EndpointInput.val(), - }; - - Craft.sendActionRequest('POST', 'aws-s3/buckets/load-bucket-data', { data }) - .then(({ data }) => { - if (!data.buckets.length) { - return; - } - - const currentBucket = $s3BucketSelect.val(); - let currentBucketStillExists = false; - - refreshingS3Buckets = true; - - $s3BucketSelect.prop('readonly', false).empty(); - - for (let i = 0; i < data.buckets.length; i++) { - if (data.buckets[i].bucket === currentBucket) { - currentBucketStillExists = true; - } - - $s3BucketSelect.append( - '' - ); - } - - if (currentBucketStillExists) { - $s3BucketSelect.val(currentBucket); - } - - refreshingS3Buckets = false; - - if (!currentBucketStillExists) { - $s3BucketSelect.trigger('change'); - } - }) - .catch(({ response }) => { - alert(response.data.message); - }) - .finally(() => { - $s3RefreshBucketsBtn.removeClass('disabled'); - $s3RefreshBucketsSpinner.addClass('hidden'); - }); - }); - - $s3BucketSelect.change(function () { - if (refreshingS3Buckets) { - return; + refreshingS3Buckets = false; + + if (!currentBucketStillExists) { + $s3BucketSelect.trigger('change'); } + }) + .catch(({response}) => { + alert(response.data.message); + }) + .finally(() => { + $s3RefreshBucketsBtn.removeClass('disabled'); + $s3RefreshBucketsSpinner.addClass('hidden'); + }); + }); + + $s3BucketSelect.change(function () { + if (refreshingS3Buckets) { + return; + } - const $selectedOption = $s3BucketSelect.children('option:selected'); + const $selectedOption = $s3BucketSelect.children('option:selected'); - $fsUrl.val($selectedOption.data('url-prefix')); - $s3Region.val($selectedOption.data('region')); - }); + $fsUrl.val($selectedOption.data('url-prefix')); + $s3Region.val($selectedOption.data('region')); + }); - function s3ChangeExpiryValue() { - const parent = $(this).parents('.field'); - const amount = parent.find('.s3-expires-amount').val(); - const period = parent.find('.s3-expires-period select').val(); + const s3ChangeExpiryValue = function () { + const parent = $(this).parents('.field'); + const amount = parent.find('.s3-expires-amount').val(); + const period = parent.find('.s3-expires-period select').val(); - const combinedValue = parseInt(amount, 10) === 0 || period.length === 0 ? '' : amount + ' ' + period; + const combinedValue = + parseInt(amount, 10) === 0 || period.length === 0 + ? '' + : amount + ' ' + period; - parent.find('[type=hidden]').val(combinedValue); - } + parent.find('[type=hidden]').val(combinedValue); + }; - $('.s3-expires-amount').keyup(s3ChangeExpiryValue).change(s3ChangeExpiryValue); - $('.s3-expires-period select').change(s3ChangeExpiryValue); + $('.s3-expires-amount') + .keyup(s3ChangeExpiryValue) + .change(s3ChangeExpiryValue); + $('.s3-expires-period select').change(s3ChangeExpiryValue); - function maybeUpdateUrl() { - if ($s3EndpointInput.val().length) { - return; - } + const maybeUpdateUrl = function () { + if ($s3EndpointInput.val().length) { + return; + } - if ($hasUrls.val() && $manualBucket.val().length && $manualRegion.val().length) { - $fsUrl.val(`https://s3.${$manualRegion.val()}.amazonaws.com/${$manualBucket.val()}/`); - } + if ( + $hasUrls.val() && + $manualBucket.val().length && + $manualRegion.val().length + ) { + $fsUrl.val( + 'https://s3.' + + $manualRegion.val() + + '.amazonaws.com/' + + $manualBucket.val() + + '/' + ); } + }; - $manualRegion.keyup(maybeUpdateUrl); - $manualBucket.keyup(maybeUpdateUrl); - $s3EndpointInput.keyup(maybeUpdateUrl).change(maybeUpdateUrl); + $manualRegion.keyup(maybeUpdateUrl); + $manualBucket.keyup(maybeUpdateUrl); + $s3EndpointInput.keyup(maybeUpdateUrl).change(maybeUpdateUrl); }); From a99b98f84a42d49861c532d38d13f3f092b2b99a Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 21 Apr 2026 10:23:03 -0400 Subject: [PATCH 4/6] Add per-filesystem STS toggle --- CHANGELOG.md | 3 ++- README.md | 6 ++++-- src/Fs.php | 16 +++++++++++++++- src/controllers/BucketsController.php | 3 ++- src/resources/js/editVolume.js | 2 ++ src/templates/fsSettings.html | 9 ++++++++- 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 503c448..5348ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -- Added a per-filesystem connection type and endpoint override for MinIO and other S3-compatible storage services, without changing the default Amazon S3 behavior. +- 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 diff --git a/README.md b/README.md index 0c12246..a92239e 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,11 @@ 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 a MinIO or other S3-compatible service, provide the service’s **Endpoint URL**. +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 using the provided access key and secret, without requesting temporary credentials from AWS STS. Leave the endpoint URL empty to preserve the plugin’s standard AWS behavior. +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 diff --git a/src/Fs.php b/src/Fs.php index cd71c88..5e41678 100644 --- a/src/Fs.php +++ b/src/Fs.php @@ -110,6 +110,11 @@ public static function displayName(): string */ public string $endpoint = ''; + /** + * @var bool Whether STS temporary credentials should be used + */ + public bool $useSts = true; + /** * @var string Cache expiration period. */ @@ -227,9 +232,10 @@ public static function loadBucketList( ?string $secret, ?string $region = null, ?string $endpoint = null, + bool $useSts = false, ): array { // Any region will do. - $config = self::buildConfigArray($keyId, $secret, $region ?? 'us-east-1', false, $endpoint); + $config = self::buildConfigArray($keyId, $secret, $region ?? 'us-east-1', false, $endpoint, $useSts); $client = static::client($config); @@ -468,6 +474,7 @@ public function detectFocalPoint(string $filePath): array * @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( @@ -476,6 +483,7 @@ public static function buildConfigArray( ?string $region = null, bool $refreshToken = false, ?string $endpoint = null, + bool $useSts = false, ): array { $config = [ 'region' => $region, @@ -489,7 +497,9 @@ public static function buildConfigArray( if ($endpoint) { $config['endpoint'] = $endpoint; + } + if (!$useSts) { if (!empty($keyId) && !empty($secret)) { $config['credentials'] = new Credentials($keyId, $secret); } @@ -608,6 +618,7 @@ private function _getS3ConfigArray(): array $credentials['region'], false, Craft::parseEnv($this->endpoint), + $this->useSts, ); } @@ -624,6 +635,9 @@ private function _getAwsConfigArray(): array $credentials['keyId'], $credentials['secret'], $credentials['region'], + false, + null, + $this->useSts, ); } diff --git a/src/controllers/BucketsController.php b/src/controllers/BucketsController.php index 61aa497..a04c74c 100644 --- a/src/controllers/BucketsController.php +++ b/src/controllers/BucketsController.php @@ -34,10 +34,11 @@ public function actionLoadBucketData(): Response $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, $region, $endpoint), + '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 6beca1f..9111741 100644 --- a/src/resources/js/editVolume.js +++ b/src/resources/js/editVolume.js @@ -2,6 +2,7 @@ $(document).ready(function () { const $s3AccessKeyIdInput = $('.s3-key-id'); const $s3SecretAccessKeyInput = $('.s3-secret-key'); const $s3EndpointInput = $('.s3-endpoint'); + const $s3UseStsInput = $('input[name=useSts]'); const $s3BucketSelect = $('.s3-bucket-select > select'); const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn @@ -28,6 +29,7 @@ $(document).ready(function () { secret: $s3SecretAccessKeyInput.val(), region: $s3Region.val(), endpoint: $s3EndpointInput.val(), + useSts: $s3UseStsInput.prop('checked'), }; Craft.sendActionRequest('POST', 'aws-s3/buckets/load-bucket-data', {data}) diff --git a/src/templates/fsSettings.html b/src/templates/fsSettings.html index f0fbda9..2a87c00 100644 --- a/src/templates/fsSettings.html +++ b/src/templates/fsSettings.html @@ -24,7 +24,7 @@ {{ forms.autosuggestField({ label: "Endpoint URL"|t('aws-s3'), - instructions: 'Provide the S3 API endpoint for your compatible storage service, such as MinIO. Leave this empty for Amazon S3.'|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, @@ -33,6 +33,13 @@ class: 'ltr s3-endpoint' }) }} +{{ forms.lightswitchField({ + 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'), + name: 'useSts', + on: fs.useSts, +}) }} + {% set bucketInput %}
{{ forms.select({ From d739ff46b7c0007b0c13ed67be3b537284766bda Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 21 Apr 2026 10:25:43 -0400 Subject: [PATCH 5/6] Allow STS toggle to use env vars --- src/Fs.php | 6 +++--- src/templates/fsSettings.html | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Fs.php b/src/Fs.php index 5e41678..75b029b 100644 --- a/src/Fs.php +++ b/src/Fs.php @@ -113,7 +113,7 @@ public static function displayName(): string /** * @var bool Whether STS temporary credentials should be used */ - public bool $useSts = true; + public bool|string $useSts = true; /** * @var string Cache expiration period. @@ -618,7 +618,7 @@ private function _getS3ConfigArray(): array $credentials['region'], false, Craft::parseEnv($this->endpoint), - $this->useSts, + App::parseBooleanEnv($this->useSts) ?? true, ); } @@ -637,7 +637,7 @@ private function _getAwsConfigArray(): array $credentials['region'], false, null, - $this->useSts, + App::parseBooleanEnv($this->useSts) ?? true, ); } diff --git a/src/templates/fsSettings.html b/src/templates/fsSettings.html index 2a87c00..060f198 100644 --- a/src/templates/fsSettings.html +++ b/src/templates/fsSettings.html @@ -33,11 +33,13 @@ class: 'ltr s3-endpoint' }) }} -{{ forms.lightswitchField({ +{{ 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', - on: fs.useSts, + includeEnvVars: true, + value: fs.useSts, }) }} {% set bucketInput %} From 9da368aeed3e2cef43146e3381bc10bf8d67ce9f Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Tue, 21 Apr 2026 10:32:09 -0400 Subject: [PATCH 6/6] Use class selector --- src/resources/js/editVolume.js | 2 +- src/templates/fsSettings.html | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resources/js/editVolume.js b/src/resources/js/editVolume.js index 9111741..07b40ef 100644 --- a/src/resources/js/editVolume.js +++ b/src/resources/js/editVolume.js @@ -2,7 +2,7 @@ $(document).ready(function () { const $s3AccessKeyIdInput = $('.s3-key-id'); const $s3SecretAccessKeyInput = $('.s3-secret-key'); const $s3EndpointInput = $('.s3-endpoint'); - const $s3UseStsInput = $('input[name=useSts]'); + const $s3UseStsInput = $('.s3-use-sts'); const $s3BucketSelect = $('.s3-bucket-select > select'); const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn diff --git a/src/templates/fsSettings.html b/src/templates/fsSettings.html index 060f198..f39c0bc 100644 --- a/src/templates/fsSettings.html +++ b/src/templates/fsSettings.html @@ -40,6 +40,7 @@ name: 'useSts', includeEnvVars: true, value: fs.useSts, + class: 'ltr s3-use-sts', }) }} {% set bucketInput %}