├── LICENSE.md ├── composer.json └── src ├── AwsS3Bundle.php ├── Fs.php ├── Plugin.php ├── S3Client.php ├── controllers └── BucketsController.php ├── icon.svg ├── migrations ├── Install.php └── m220119_204627_update_fs_configs.php ├── resources └── js │ └── editVolume.js └── templates └── fsSettings.html /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Pixel & Tonic, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "craftcms/aws-s3", 3 | "description": "Amazon S3 integration for Craft CMS", 4 | "type": "craft-plugin", 5 | "keywords": [ 6 | "aws", 7 | "cms", 8 | "craftcms", 9 | "flysystem", 10 | "s3", 11 | "yii2" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Pixel & Tonic", 17 | "homepage": "https://pixelandtonic.com/" 18 | } 19 | ], 20 | "support": { 21 | "email": "support@craftcms.com", 22 | "issues": "https://github.com/craftcms/aws-s3/issues?state=open", 23 | "source": "https://github.com/craftcms/aws-s3", 24 | "docs": "https://github.com/craftcms/aws-s3/blob/master/README.md", 25 | "rss": "https://github.com/craftcms/aws-s3/commits/master.atom" 26 | }, 27 | "minimum-stability": "dev", 28 | "prefer-stable": true, 29 | "require": { 30 | "php": "^8.0.2", 31 | "craftcms/cms": "^4.0.0-beta.1|^5.0.0-beta.1", 32 | "craftcms/flysystem": "^1.0.0-beta.2|^2.0.0", 33 | "league/flysystem-aws-s3-v3": "^3.0.0" 34 | }, 35 | "require-dev": { 36 | "craftcms/ecs": "dev-main", 37 | "craftcms/phpstan": "dev-main", 38 | "craftcms/rector": "dev-main" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "craft\\awss3\\": "src/" 43 | } 44 | }, 45 | "scripts": { 46 | "check-cs": "ecs check --ansi", 47 | "fix-cs": "ecs check --ansi --fix", 48 | "phpstan": "phpstan --memory-limit=1G" 49 | }, 50 | "extra": { 51 | "name": "Amazon S3", 52 | "handle": "aws-s3", 53 | "documentationUrl": "https://github.com/craftcms/aws-s3/blob/master/README.md" 54 | }, 55 | "config": { 56 | "platform": { 57 | "php": "8.0.2" 58 | }, 59 | "allow-plugins": { 60 | "yiisoft/yii2-composer": true, 61 | "craftcms/plugin-installer": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/AwsS3Bundle.php: -------------------------------------------------------------------------------- 1 | 42 | * @since 1.0 43 | */ 44 | class Fs extends FlysystemFs 45 | { 46 | // Constants 47 | // ========================================================================= 48 | 49 | public const STORAGE_STANDARD = 'STANDARD'; 50 | public const STORAGE_REDUCED_REDUNDANCY = 'REDUCED_REDUNDANCY'; 51 | public const STORAGE_STANDARD_IA = 'STANDARD_IA'; 52 | 53 | /** 54 | * Cache key to use for caching purposes 55 | */ 56 | public const CACHE_KEY_PREFIX = 'aws.'; 57 | 58 | /** 59 | * Cache duration for access token 60 | */ 61 | public const CACHE_DURATION_SECONDS = 3600; 62 | 63 | // Static 64 | // ========================================================================= 65 | 66 | /** 67 | * @inheritdoc 68 | */ 69 | public static function displayName(): string 70 | { 71 | return 'Amazon S3'; 72 | } 73 | 74 | // Properties 75 | // ========================================================================= 76 | 77 | /** 78 | * @var string Subfolder to use 79 | */ 80 | public string $subfolder = ''; 81 | 82 | /** 83 | * @var string AWS key ID 84 | */ 85 | public string $keyId = ''; 86 | 87 | /** 88 | * @var string AWS key secret 89 | */ 90 | public string $secret = ''; 91 | 92 | /** 93 | * @var string Bucket selection mode ('choose' or 'manual') 94 | */ 95 | public string $bucketSelectionMode = 'choose'; 96 | 97 | /** 98 | * @var string Bucket to use 99 | */ 100 | public string $bucket = ''; 101 | 102 | /** 103 | * @var string Region to use 104 | */ 105 | public string $region = ''; 106 | 107 | /** 108 | * @var string Cache expiration period. 109 | */ 110 | public string $expires = ''; 111 | 112 | /** 113 | * @var bool Set ACL for Uploads 114 | */ 115 | public bool $makeUploadsPublic = true; 116 | 117 | /** 118 | * @var string S3 storage class to use. 119 | * @deprecated in 1.1.1 120 | */ 121 | public string $storageClass = ''; 122 | 123 | /** 124 | * @var string CloudFront Distribution ID 125 | */ 126 | public string $cfDistributionId = ''; 127 | 128 | /** 129 | * @var string CloudFront Distribution Prefix 130 | */ 131 | public string $cfPrefix = ''; 132 | 133 | /** 134 | * @var bool Whether facial detection should be attempted to set the focal point automatically 135 | */ 136 | public bool $autoFocalPoint = false; 137 | 138 | /** 139 | * @var bool Whether the specified sub folder should be added to the root URL 140 | */ 141 | public bool $addSubfolderToRootUrl = true; 142 | 143 | /** 144 | * @var array A list of paths to invalidate at the end of request. 145 | */ 146 | protected array $pathsToInvalidate = []; 147 | 148 | // Public Methods 149 | // ========================================================================= 150 | 151 | /** 152 | * @inheritdoc 153 | */ 154 | public function __construct(array $config = []) 155 | { 156 | if (isset($config['manualBucket'])) { 157 | if (isset($config['bucketSelectionMode']) && $config['bucketSelectionMode'] === 'manual') { 158 | $config['bucket'] = ArrayHelper::remove($config, 'manualBucket'); 159 | $config['region'] = ArrayHelper::remove($config, 'manualRegion'); 160 | } else { 161 | unset($config['manualBucket'], $config['manualRegion']); 162 | } 163 | } 164 | 165 | parent::__construct($config); 166 | } 167 | 168 | /** 169 | * @inheritdoc 170 | */ 171 | public function behaviors(): array 172 | { 173 | $behaviors = parent::behaviors(); 174 | $behaviors['parser'] = [ 175 | 'class' => EnvAttributeParserBehavior::class, 176 | 'attributes' => [ 177 | 'keyId', 178 | 'secret', 179 | 'bucket', 180 | 'region', 181 | 'subfolder', 182 | 'cfDistributionId', 183 | 'cfPrefix', 184 | ], 185 | ]; 186 | return $behaviors; 187 | } 188 | 189 | /** 190 | * @inheritdoc 191 | */ 192 | protected function defineRules(): array 193 | { 194 | return array_merge(parent::defineRules(), [ 195 | [['bucket', 'region'], 'required'], 196 | ]); 197 | } 198 | 199 | /** 200 | * @inheritdoc 201 | */ 202 | public function getSettingsHtml(): ?string 203 | { 204 | return Craft::$app->getView()->renderTemplate('aws-s3/fsSettings', [ 205 | 'fs' => $this, 206 | 'periods' => array_merge(['' => ''], Assets::periodList()), 207 | ]); 208 | } 209 | 210 | /** 211 | * Get the bucket list using the specified credentials. 212 | * 213 | * @param string|null $keyId The key ID 214 | * @param string|null $secret The key secret 215 | * @return array 216 | * @throws InvalidArgumentException 217 | */ 218 | public static function loadBucketList(?string $keyId, ?string $secret): array 219 | { 220 | // Any region will do. 221 | $config = self::buildConfigArray($keyId, $secret, 'us-east-1'); 222 | 223 | $client = static::client($config); 224 | 225 | $objects = $client->listBuckets(); 226 | 227 | if (empty($objects['Buckets'])) { 228 | return []; 229 | } 230 | 231 | $buckets = $objects['Buckets']; 232 | $bucketList = []; 233 | 234 | foreach ($buckets as $bucket) { 235 | try { 236 | $region = $client->determineBucketRegion($bucket['Name']); 237 | } catch (S3Exception $exception) { 238 | 239 | // If a bucket cannot be accessed by the current policy, move along: 240 | // https://github.com/craftcms/aws-s3/pull/29#issuecomment-468193410 241 | continue; 242 | } 243 | 244 | if (str_contains($bucket['Name'], '.')) { 245 | $urlPrefix = 'https://s3.' . $region . '.amazonaws.com/' . $bucket['Name'] . '/'; 246 | } else { 247 | $urlPrefix = 'https://' . $bucket['Name'] . '.s3.amazonaws.com/'; 248 | } 249 | 250 | $bucketList[] = [ 251 | 'bucket' => $bucket['Name'], 252 | 'urlPrefix' => $urlPrefix, 253 | 'region' => $region, 254 | ]; 255 | } 256 | 257 | return $bucketList; 258 | } 259 | 260 | /** 261 | * @inheritdoc 262 | */ 263 | public function getRootUrl(): ?string 264 | { 265 | $rootUrl = parent::getRootUrl(); 266 | 267 | if ($rootUrl) { 268 | $rootUrl .= $this->_getRootUrlPath(); 269 | } 270 | 271 | return $rootUrl; 272 | } 273 | 274 | // Protected Methods 275 | // ========================================================================= 276 | 277 | /** 278 | * @inheritdoc 279 | * @return AwsS3V3Adapter 280 | */ 281 | protected function createAdapter(): FilesystemAdapter 282 | { 283 | $client = static::client($this->_getConfigArray(), $this->_getCredentials()); 284 | $options = [ 285 | 286 | // This is the S3 default for all objects, but explicitly 287 | // sending the header allows for bucket policies that require it. 288 | // @see https://github.com/craftcms/aws-s3/pull/172 289 | 'ServerSideEncryption' => 'AES256', 290 | ]; 291 | 292 | return new AwsS3V3Adapter( 293 | $client, 294 | App::parseEnv($this->bucket), 295 | $this->_subfolder(), 296 | new PortableVisibilityConverter($this->visibility()), 297 | null, 298 | $options, 299 | false, 300 | ); 301 | } 302 | 303 | /** 304 | * Get the Amazon S3 client. 305 | * 306 | * @param array $config client config 307 | * @param array $credentials credentials to use when generating a new token 308 | * @return S3Client 309 | */ 310 | protected static function client(array $config = [], array $credentials = []): S3Client 311 | { 312 | if (!empty($config['credentials']) && $config['credentials'] instanceof Credentials) { 313 | $config['generateNewConfig'] = static function() use ($credentials) { 314 | $args = [ 315 | $credentials['keyId'], 316 | $credentials['secret'], 317 | $credentials['region'], 318 | true, 319 | ]; 320 | return call_user_func_array(self::class . '::buildConfigArray', $args); 321 | }; 322 | } 323 | 324 | return new S3Client($config); 325 | } 326 | 327 | /** 328 | * @inheritdoc 329 | */ 330 | protected function addFileMetadataToConfig(array $config): array 331 | { 332 | if (!empty($this->expires) && DateTimeHelper::isValidIntervalString($this->expires)) { 333 | $expires = new DateTime(); 334 | $now = new DateTime(); 335 | $expires->modify('+' . $this->expires); 336 | $diff = (int)$expires->format('U') - (int)$now->format('U'); 337 | $config['CacheControl'] = 'max-age=' . $diff; 338 | } 339 | 340 | return parent::addFileMetadataToConfig($config); 341 | } 342 | 343 | /** 344 | * @inheritdoc 345 | */ 346 | protected function invalidateCdnPath(string $path): bool 347 | { 348 | if (!empty($this->cfDistributionId)) { 349 | if (empty($this->pathsToInvalidate)) { 350 | Craft::$app->on(Application::EVENT_AFTER_REQUEST, [$this, 'purgeQueuedPaths']); 351 | } 352 | 353 | // Ensure our paths are prefixed with configured subfolder 354 | $path = $this->_getRootUrlPath() . $path; 355 | 356 | $this->pathsToInvalidate[$path] = true; 357 | } 358 | 359 | return true; 360 | } 361 | 362 | /** 363 | * Purge any queued paths from the CDN. 364 | */ 365 | public function purgeQueuedPaths(): void 366 | { 367 | if (!empty($this->pathsToInvalidate)) { 368 | // If there's a CloudFront distribution ID set, invalidate the path. 369 | $cfClient = $this->_getCloudFrontClient(); 370 | $items = []; 371 | 372 | foreach ($this->pathsToInvalidate as $path => $bool) { 373 | $items[] = '/' . $this->_cfPrefix() . ltrim($path, '/'); 374 | } 375 | 376 | try { 377 | $cfClient->createInvalidation( 378 | [ 379 | 'DistributionId' => Craft::parseEnv($this->cfDistributionId), 380 | 'InvalidationBatch' => [ 381 | 'Paths' => 382 | [ 383 | 'Quantity' => count($items), 384 | 'Items' => $items, 385 | ], 386 | 'CallerReference' => 'Craft-' . StringHelper::randomString(24), 387 | ], 388 | ] 389 | ); 390 | } catch (CloudFrontException $exception) { 391 | // Log the warning, most likely due to 404. Allow the operation to continue, though. 392 | Craft::warning($exception->getMessage()); 393 | } 394 | } 395 | } 396 | 397 | /** 398 | * Attempt to detect focal point for a path on the bucket and return the 399 | * focal point position as an array of decimal parts 400 | * 401 | * @param string $filePath 402 | * @return array 403 | */ 404 | public function detectFocalPoint(string $filePath): array 405 | { 406 | $extension = StringHelper::toLowerCase(pathinfo($filePath, PATHINFO_EXTENSION)); 407 | 408 | if (!in_array($extension, ['jpeg', 'jpg', 'png'])) { 409 | return []; 410 | } 411 | 412 | 413 | $client = new RekognitionClient($this->_getConfigArray()); 414 | $params = [ 415 | 'Image' => [ 416 | 'S3Object' => [ 417 | 'Name' => Craft::parseEnv($filePath), 418 | 'Bucket' => Craft::parseEnv($this->bucket), 419 | ], 420 | ], 421 | ]; 422 | 423 | $faceData = $client->detectFaces($params); 424 | 425 | if (!empty($faceData['FaceDetails'])) { 426 | $face = array_shift($faceData['FaceDetails']); 427 | if ($face['Confidence'] > 80) { 428 | $box = $face['BoundingBox']; 429 | return [ 430 | number_format($box['Left'] + ($box['Width'] / 2), 4), 431 | number_format($box['Top'] + ($box['Height'] / 2), 4), 432 | ]; 433 | } 434 | } 435 | 436 | return []; 437 | } 438 | 439 | /** 440 | * Build the config array based on a keyID and secret 441 | * 442 | * @param ?string $keyId The key ID 443 | * @param ?string $secret The key secret 444 | * @param ?string $region The region to user 445 | * @param bool $refreshToken If true will always refresh token 446 | * @return array 447 | */ 448 | public static function buildConfigArray(?string $keyId = null, ?string $secret = null, ?string $region = null, bool $refreshToken = false): array 449 | { 450 | $config = [ 451 | 'region' => $region, 452 | 'version' => 'latest', 453 | ]; 454 | 455 | $client = Craft::createGuzzleClient(); 456 | $config['http_handler'] = new GuzzleHandler($client); 457 | 458 | /** @noinspection MissingOrEmptyGroupStatementInspection */ 459 | if (empty($keyId) || empty($secret)) { 460 | // Check for predefined access 461 | if (App::env('AWS_WEB_IDENTITY_TOKEN_FILE') && App::env('AWS_ROLE_ARN')) { 462 | // Check if anything is defined for a web identity provider (see: https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials_provider.html#assume-role-with-web-identity-provider) 463 | $provider = CredentialProvider::assumeRoleWithWebIdentityCredentialProvider(); 464 | $provider = CredentialProvider::memoize($provider); 465 | $config['credentials'] = $provider; 466 | } 467 | // Check if running on ECS 468 | if (App::env('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI')) { 469 | // Check if anything is defined for an ecsCredentials provider 470 | $provider = CredentialProvider::ecsCredentials(); 471 | $provider = CredentialProvider::memoize($provider); 472 | $config['credentials'] = $provider; 473 | } 474 | // If that didn't happen, assume we're running on EC2 and we have an IAM role assigned so no action required. 475 | } else { 476 | $tokenKey = static::CACHE_KEY_PREFIX . md5($keyId . $secret); 477 | $credentials = new Credentials($keyId, $secret); 478 | 479 | if (Craft::$app->cache->exists($tokenKey) && !$refreshToken) { 480 | $cached = Craft::$app->cache->get($tokenKey); 481 | $credentials->unserialize($cached); 482 | } else { 483 | $config['credentials'] = $credentials; 484 | $stsClient = new StsClient($config); 485 | $result = $stsClient->getSessionToken(['DurationSeconds' => static::CACHE_DURATION_SECONDS]); 486 | $credentials = $stsClient->createCredentials($result); 487 | $cacheDuration = $credentials->getExpiration() - time(); 488 | $cacheDuration = $cacheDuration > 0 ? $cacheDuration : static::CACHE_DURATION_SECONDS; 489 | Craft::$app->cache->set($tokenKey, $credentials->serialize(), $cacheDuration); 490 | } 491 | 492 | // TODO Add support for different credential supply methods 493 | $config['credentials'] = $credentials; 494 | } 495 | 496 | return $config; 497 | } 498 | 499 | // Private Methods 500 | // ========================================================================= 501 | 502 | /** 503 | * Returns the parsed subfolder path 504 | * 505 | * @return string 506 | */ 507 | private function _subfolder(): string 508 | { 509 | if ($this->subfolder && ($subfolder = rtrim(Craft::parseEnv($this->subfolder), '/')) !== '') { 510 | return $subfolder . '/'; 511 | } 512 | 513 | return ''; 514 | } 515 | 516 | /** 517 | * Returns the root path for URLs 518 | * 519 | * @return string 520 | */ 521 | private function _getRootUrlPath(): string 522 | { 523 | if ($this->addSubfolderToRootUrl) { 524 | return $this->_subfolder(); 525 | } 526 | return ''; 527 | } 528 | 529 | /** 530 | * Returns the parsed CloudFront distribution prefix 531 | * 532 | * @return string 533 | */ 534 | private function _cfPrefix(): string 535 | { 536 | if ($this->cfPrefix && ($cfPrefix = rtrim(Craft::parseEnv($this->cfPrefix), '/')) !== '') { 537 | return $cfPrefix . '/'; 538 | } 539 | 540 | return ''; 541 | } 542 | 543 | /** 544 | * Get a CloudFront client. 545 | * 546 | * @return CloudFrontClient 547 | */ 548 | private function _getCloudFrontClient(): CloudFrontClient 549 | { 550 | return new CloudFrontClient($this->_getConfigArray()); 551 | } 552 | 553 | /** 554 | * Get the config array for AWS Clients. 555 | * 556 | * @return array 557 | */ 558 | private function _getConfigArray(): array 559 | { 560 | $credentials = $this->_getCredentials(); 561 | 562 | return self::buildConfigArray($credentials['keyId'], $credentials['secret'], $credentials['region']); 563 | } 564 | 565 | /** 566 | * Return the credentials as an array 567 | * 568 | * @return array 569 | */ 570 | private function _getCredentials(): array 571 | { 572 | return [ 573 | 'keyId' => Craft::parseEnv($this->keyId), 574 | 'secret' => Craft::parseEnv($this->secret), 575 | 'region' => Craft::parseEnv($this->region), 576 | ]; 577 | } 578 | 579 | /** 580 | * Returns the visibility setting for the Fs. 581 | * 582 | * @return string 583 | */ 584 | protected function visibility(): string 585 | { 586 | return $this->makeUploadsPublic ? Visibility::PUBLIC : Visibility::PRIVATE; 587 | } 588 | } 589 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class Plugin extends \craft\base\Plugin 23 | { 24 | // Properties 25 | // ========================================================================= 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public string $schemaVersion = '2.0'; 31 | 32 | // Public Methods 33 | // ========================================================================= 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | public function init() 39 | { 40 | parent::init(); 41 | 42 | Event::on(FsService::class, FsService::EVENT_REGISTER_FILESYSTEM_TYPES, function(RegisterComponentTypesEvent $event) { 43 | $event->types[] = Fs::class; 44 | }); 45 | 46 | Event::on(Asset::class, Element::EVENT_AFTER_SAVE, function(ModelEvent $event) { 47 | if (!$event->isNew) { 48 | return; 49 | } 50 | 51 | /** @var Asset $asset */ 52 | $asset = $event->sender; 53 | $volume = $asset->getVolume(); 54 | $filesystem = $volume->getFs(); 55 | 56 | if (!$filesystem instanceof Fs || !$filesystem->autoFocalPoint) { 57 | return; 58 | } 59 | 60 | $fullPath = (!empty($filesystem->subfolder) ? rtrim($filesystem->subfolder, '/') . '/' : '') . 61 | (method_exists($volume, 'getSubpath') ? $volume->getSubpath() : '') . 62 | $asset->getPath(); 63 | 64 | $focalPoint = $filesystem->detectFocalPoint($fullPath); 65 | 66 | if (!empty($focalPoint)) { 67 | $assetRecord = \craft\records\Asset::findOne($asset->id); 68 | $assetRecord->focalPoint = min(max($focalPoint[0], 0), 1) . ';' . min(max($focalPoint[1], 0), 1); 69 | $assetRecord->save(); 70 | } 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/S3Client.php: -------------------------------------------------------------------------------- 1 | 18 | * @since 1.3 19 | */ 20 | class S3Client extends AwsS3Client 21 | { 22 | /** 23 | * @var callable callback for generating new config, including new credentials. 24 | */ 25 | private $_generateNewConfig; 26 | 27 | /** 28 | * @var AwsS3Client the wrapped AWS client to use for all requests 29 | */ 30 | private AwsS3Client $_wrappedClient; 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function __construct(array $args) 36 | { 37 | if (!empty($args['generateNewConfig'])) { 38 | $this->_generateNewConfig = $args['generateNewConfig']; 39 | unset($args['generateNewConfig']); 40 | } 41 | 42 | // Create an instance of parent class to use. 43 | $this->_wrappedClient = new parent($args); 44 | 45 | parent::__construct($args); 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public function executeAsync(CommandInterface $command) 52 | { 53 | try { 54 | // Just try to execute 55 | return $this->_wrappedClient->executeAsync($command); 56 | } catch (S3Exception $exception) { 57 | // Attempt to get new credentials 58 | if ($exception->getAwsErrorCode() == 'ExpiredToken') { 59 | $clientConfig = call_user_func($this->_generateNewConfig); 60 | $this->_wrappedClient = new parent($clientConfig); 61 | 62 | // Re-create the command to use the new credentials 63 | $newCommand = $this->getCommand($command->getName(), $command->toArray()); 64 | return $this->_wrappedClient->executeAsync($newCommand); 65 | } 66 | 67 | throw $exception; 68 | } 69 | } 70 | 71 | /** 72 | * @inheritdoc 73 | */ 74 | public function getCommand($name, array $args = []) 75 | { 76 | // Use the wrapped client which should have the latest credentials. 77 | return $this->_wrappedClient->getCommand($name, $args); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/controllers/BucketsController.php: -------------------------------------------------------------------------------- 1 | 15 | * @since 3.0 16 | */ 17 | class BucketsController extends BaseController 18 | { 19 | /** 20 | * @inheritdoc 21 | */ 22 | public function init(): void 23 | { 24 | parent::init(); 25 | $this->defaultAction = 'load-bucket-data'; 26 | } 27 | 28 | /** 29 | * Load bucket data for specified credentials. 30 | * 31 | * @return Response 32 | */ 33 | public function actionLoadBucketData(): Response 34 | { 35 | $this->requirePostRequest(); 36 | $this->requireAcceptsJson(); 37 | 38 | $request = Craft::$app->getRequest(); 39 | $keyId = App::parseEnv($request->getRequiredBodyParam('keyId')); 40 | $secret = App::parseEnv($request->getRequiredBodyParam('secret')); 41 | 42 | try { 43 | return $this->asJson([ 44 | 'buckets' => Fs::loadBucketList($keyId, $secret), 45 | ]); 46 | } catch (\Throwable $e) { 47 | return $this->asFailure($e->getMessage()); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/migrations/Install.php: -------------------------------------------------------------------------------- 1 | 19 | * @since 1.0 20 | */ 21 | class Install extends Migration 22 | { 23 | // Public Methods 24 | // ========================================================================= 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function safeUp(): bool 30 | { 31 | // Update any old S3 configs 32 | $projectConfig = Craft::$app->getProjectConfig(); 33 | $fsConfigs = $projectConfig->get(ProjectConfig::PATH_FS) ?? []; 34 | 35 | foreach ($fsConfigs as $uid => $config) { 36 | if ( 37 | in_array($config['type'], ['craft\awss3\Volume', Fs::class]) && 38 | isset($config['settings']) && 39 | is_array($config['settings']) 40 | ) { 41 | $config['type'] = Fs::class; 42 | $settings = &$config['settings']; 43 | 44 | if (array_key_exists('urlPrefix', $settings)) { 45 | $config['url'] = (($config['hasUrls'] ?? false) && $settings['urlPrefix']) ? $settings['urlPrefix'] : null; 46 | } 47 | 48 | if (array_key_exists('location', $settings)) { 49 | $config['region'] = $settings['location']; 50 | } 51 | 52 | if ( 53 | isset($settings['expires']) && 54 | preg_match('/^([\d]+)([a-z]+)$/', $settings['expires'], $matches) 55 | ) { 56 | $settings['expires'] = sprintf('%s %s', $matches[1], $matches[2]); 57 | } 58 | 59 | unset($settings['urlPrefix'], $settings['location'], $settings['storageClass']); 60 | $projectConfig->set(sprintf('%s.%s', ProjectConfig::PATH_FS, $uid), $config); 61 | } 62 | } 63 | 64 | return true; 65 | } 66 | 67 | /** 68 | * @inheritdoc 69 | */ 70 | public function safeDown(): bool 71 | { 72 | return true; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/migrations/m220119_204627_update_fs_configs.php: -------------------------------------------------------------------------------- 1 | getProjectConfig()->get('plugins.aws-s3.schemaVersion', true); 20 | if (version_compare($schemaVersion, '2.0', '>=')) { 21 | return true; 22 | } 23 | 24 | // Just re-run the install migration 25 | (new Install())->safeUp(); 26 | return true; 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function safeDown(): bool 33 | { 34 | echo "m220119_204627_update_fs_configs cannot be reverted.\n"; 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/resources/js/editVolume.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | const $s3AccessKeyIdInput = $('.s3-key-id'); 3 | const $s3SecretAccessKeyInput = $('.s3-secret-key'); 4 | const $s3BucketSelect = $('.s3-bucket-select > select'); 5 | const $s3RefreshBucketsBtn = $('.s3-refresh-buckets'); 6 | const $s3RefreshBucketsSpinner = $s3RefreshBucketsBtn 7 | .parent() 8 | .next() 9 | .children(); 10 | const $s3Region = $('.s3-region'); 11 | const $manualBucket = $('.s3-manualBucket'); 12 | const $manualRegion = $('.s3-manualRegion'); 13 | const $fsUrl = $('.fs-url'); 14 | const $hasUrls = $('input[name=hasUrls]'); 15 | let refreshingS3Buckets = false; 16 | 17 | $s3RefreshBucketsBtn.click(function () { 18 | if ($s3RefreshBucketsBtn.hasClass('disabled')) { 19 | return; 20 | } 21 | 22 | $s3RefreshBucketsBtn.addClass('disabled'); 23 | $s3RefreshBucketsSpinner.removeClass('hidden'); 24 | 25 | const data = { 26 | keyId: $s3AccessKeyIdInput.val(), 27 | secret: $s3SecretAccessKeyInput.val(), 28 | }; 29 | 30 | Craft.sendActionRequest('POST', 'aws-s3/buckets/load-bucket-data', {data}) 31 | .then(({data}) => { 32 | if (!data.buckets.length) { 33 | return; 34 | } 35 | // 36 | const currentBucket = $s3BucketSelect.val(); 37 | let currentBucketStillExists = false; 38 | 39 | refreshingS3Buckets = true; 40 | 41 | $s3BucketSelect.prop('readonly', false).empty(); 42 | 43 | for (let i = 0; i < data.buckets.length; i++) { 44 | if (data.buckets[i].bucket == currentBucket) { 45 | currentBucketStillExists = true; 46 | } 47 | 48 | $s3BucketSelect.append( 49 | '' 58 | ); 59 | } 60 | 61 | if (currentBucketStillExists) { 62 | $s3BucketSelect.val(currentBucket); 63 | } 64 | 65 | refreshingS3Buckets = false; 66 | 67 | if (!currentBucketStillExists) { 68 | $s3BucketSelect.trigger('change'); 69 | } 70 | }) 71 | .catch(({response}) => { 72 | alert(response.data.message); 73 | }) 74 | .finally(() => { 75 | $s3RefreshBucketsBtn.removeClass('disabled'); 76 | $s3RefreshBucketsSpinner.addClass('hidden'); 77 | }); 78 | }); 79 | 80 | $s3BucketSelect.change(function () { 81 | if (refreshingS3Buckets) { 82 | return; 83 | } 84 | 85 | const $selectedOption = $s3BucketSelect.children('option:selected'); 86 | 87 | $fsUrl.val($selectedOption.data('url-prefix')); 88 | $s3Region.val($selectedOption.data('region')); 89 | }); 90 | 91 | const s3ChangeExpiryValue = function () { 92 | const parent = $(this).parents('.field'); 93 | const amount = parent.find('.s3-expires-amount').val(); 94 | const period = parent.find('.s3-expires-period select').val(); 95 | 96 | const combinedValue = 97 | parseInt(amount, 10) === 0 || period.length === 0 98 | ? '' 99 | : amount + ' ' + period; 100 | 101 | parent.find('[type=hidden]').val(combinedValue); 102 | }; 103 | 104 | $('.s3-expires-amount') 105 | .keyup(s3ChangeExpiryValue) 106 | .change(s3ChangeExpiryValue); 107 | $('.s3-expires-period select').change(s3ChangeExpiryValue); 108 | 109 | const maybeUpdateUrl = function () { 110 | if ( 111 | $hasUrls.val() && 112 | $manualBucket.val().length && 113 | $manualRegion.val().length 114 | ) { 115 | $fsUrl.val( 116 | 'https://s3.' + 117 | $manualRegion.val() + 118 | '.amazonaws.com/' + 119 | $manualBucket.val() + 120 | '/' 121 | ); 122 | } 123 | }; 124 | 125 | $manualRegion.keyup(maybeUpdateUrl); 126 | $manualBucket.keyup(maybeUpdateUrl); 127 | }); 128 | -------------------------------------------------------------------------------- /src/templates/fsSettings.html: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | 3 | {{ forms.autosuggestField({ 4 | label: "Access Key ID"|t('aws-s3'), 5 | id: 'keyId', 6 | name: 'keyId', 7 | suggestEnvVars: true, 8 | value: fs.keyId, 9 | errors: fs.getErrors('keyId'), 10 | class: 's3-key-id', 11 | instructions: 'You can leave this field empty if you are using an EC2 instance with an applicable IAM role assignment.'|t('aws-s3') 12 | }) }} 13 | 14 | {{ forms.autosuggestField({ 15 | label: "Secret Access Key"|t('aws-s3'), 16 | id: 'secret', 17 | name: 'secret', 18 | suggestEnvVars: true, 19 | value: fs.secret, 20 | errors: fs.getErrors('secret'), 21 | class: 's3-secret-key', 22 | instructions: 'You can leave this field empty if you are using an EC2 instance with an applicable IAM role assignment.'|t('aws-s3') 23 | }) }} 24 | 25 | {% set bucketInput %} 26 |