Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
121 changes: 100 additions & 21 deletions src/Fs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -178,6 +189,7 @@ public function behaviors(): array
'secret',
'bucket',
'region',
'endpoint',
'subfolder',
'cfDistributionId',
'cfPrefix',
Expand Down Expand Up @@ -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);

Expand All @@ -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/';
}

Expand Down Expand Up @@ -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
Expand All @@ -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'],
Expand All @@ -321,6 +349,8 @@ protected static function client(array $config = [], array $credentials = []): S
};
}

unset($config['refreshableCredentials']);

return new S3Client($config);
}

Expand Down Expand Up @@ -410,7 +440,7 @@ public function detectFocalPoint(string $filePath): array
}


$client = new RekognitionClient($this->_getConfigArray());
$client = new RekognitionClient($this->_getAwsConfigArray());
$params = [
'Image' => [
'S3Object' => [
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/controllers/BucketsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
14 changes: 12 additions & 2 deletions src/resources/js/editVolume.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,14 +27,17 @@ $(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})
.then(({data}) => {
if (!data.buckets.length) {
return;
}
//

const currentBucket = $s3BucketSelect.val();
let currentBucketStillExists = false;

Expand All @@ -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;
}

Expand Down Expand Up @@ -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 &&
Expand All @@ -124,4 +133,5 @@ $(document).ready(function () {

$manualRegion.keyup(maybeUpdateUrl);
$manualBucket.keyup(maybeUpdateUrl);
$s3EndpointInput.keyup(maybeUpdateUrl).change(maybeUpdateUrl);
});
21 changes: 21 additions & 0 deletions src/templates/fsSettings.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<div class="flex fullwidth">
{{ forms.select({
Expand Down
Loading