Files
Sebastian Cruz 925481570c fix(files_external): default optional S3 key in parseParams
S3 mounts that authenticate via the AWS SDK default credential chain
(env vars, EC2 instance profile, ECS task role) carry no static `key`
param. AmazonS3::__construct() read $this->params['key'] unconditionally
when deriving the storage id, emitting an "Undefined array key" warning
on every storage construction for such mounts.

Default `key` to an empty string in S3ConnectionTrait::parseParams(),
alongside the other normalized params, so it is always set before the id
is derived. The only other reader, paramCredentialProvider(), uses an
empty() check that treats '' the same as an absent key, so the SDK
default provider chain is still used when no key is configured.

Fixes #63564

Assisted-by: Kiro:claude-opus-4.8
Signed-off-by: Sebastian Cruz <default50@gmail.com>
2026-08-31 06:51:10 -04:00

342 lines
11 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Files\ObjectStore;
use Aws\ClientResolver;
use Aws\Credentials\CredentialProvider;
use Aws\Credentials\Credentials;
use Aws\Exception\CredentialsException;
use Aws\Middleware;
use Aws\S3\Exception\S3Exception;
use Aws\S3\S3Client;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\Psr7\Utils;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\ObjectStore\Events\BucketCreatedEvent;
use OCP\Files\StorageNotAvailableException;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\ICertificateManager;
use OCP\Server;
use Psr\Http\Message\RequestInterface;
use Psr\Log\LoggerInterface;
trait S3ConnectionTrait {
use S3ConfigTrait;
protected string $id;
protected bool $test;
protected ?S3Client $connection = null;
private ?ICache $existingBucketsCache = null;
private bool $usePresignedUrl = false;
protected function parseParams($params) {
if (empty($params['bucket'])) {
throw new \Exception('Bucket has to be configured.');
}
if (isset($params['perBucket'][$params['bucket']])) {
$params = array_merge($params, $params['perBucket'][$params['bucket']]);
}
$this->id = 'amazon::' . $params['bucket'];
$this->test = isset($params['test']);
$this->bucket = $params['bucket'];
// Default to 5 like the S3 SDK does
$this->concurrency = $params['concurrency'] ?? 5;
$this->proxy = $params['proxy'] ?? false;
$this->connectTimeout = $params['connect_timeout'] ?? 5;
$this->timeout = $params['timeout'] ?? 15;
$this->storageClass = !empty($params['storageClass']) ? $params['storageClass'] : 'STANDARD';
$this->uploadPartSize = $params['uploadPartSize'] ?? 524288000;
$this->putSizeLimit = $params['putSizeLimit'] ?? 104857600;
$this->copySizeLimit = $params['copySizeLimit'] ?? 5242880000;
$this->useMultipartCopy = (bool)($params['useMultipartCopy'] ?? true);
$this->retriesMaxAttempts = $params['retriesMaxAttempts'] ?? 5;
$params['region'] = empty($params['region']) ? 'eu-west-1' : $params['region'];
$params['hostname'] = empty($params['hostname']) ? 's3.' . $params['region'] . '.amazonaws.com' : $params['hostname'];
$params['key'] = $params['key'] ?? '';
$params['s3-accelerate'] = $params['hostname'] === 's3-accelerate.amazonaws.com' || $params['hostname'] === 's3-accelerate.dualstack.amazonaws.com';
if (!isset($params['port']) || $params['port'] === '') {
$params['port'] = (isset($params['use_ssl']) && $params['use_ssl'] === false) ? 80 : 443;
}
$params['verify_bucket_exists'] = $params['verify_bucket_exists'] ?? true;
if ($params['s3-accelerate']) {
$params['verify_bucket_exists'] = false;
}
$this->params = $params;
$this->usePresignedUrl = $params['use_presigned_url'] ?? false;
}
public function getBucket() {
return $this->bucket;
}
public function getProxy() {
return $this->proxy;
}
/**
* Returns the connection
*
* @return S3Client connected client
* @throws \Exception if connection could not be made
*/
public function getConnection() {
if ($this->connection !== null) {
return $this->connection;
}
if ($this->existingBucketsCache === null) {
$this->existingBucketsCache = Server::get(ICacheFactory::class)
->createLocal('s3-bucket-exists-cache');
}
$scheme = (isset($this->params['use_ssl']) && $this->params['use_ssl'] === false) ? 'http' : 'https';
$base_url = $scheme . '://' . $this->params['hostname'] . ':' . $this->params['port'] . '/';
// Adding explicit credential provider to the beginning chain.
// Including default credential provider (skipping AWS shared config files).
$provider = CredentialProvider::memoize(
CredentialProvider::chain(
$this->paramCredentialProvider(),
CredentialProvider::defaultProvider(['use_aws_shared_config_files' => false])
)
);
$options = [
'version' => $this->params['version'] ?? 'latest',
'credentials' => $provider,
'endpoint' => $base_url,
'region' => $this->params['region'],
'use_path_style_endpoint' => isset($this->params['use_path_style']) ? $this->params['use_path_style'] : false,
'proxy' => isset($this->params['proxy']) ? $this->params['proxy'] : false,
'signature_provider' => \Aws\or_chain([self::class, 'legacySignatureProvider'], ClientResolver::_default_signature_provider()),
'csm' => false,
'use_arn_region' => false,
'http' => [
'verify' => $this->getCertificateBundlePath(),
'connect_timeout' => $this->connectTimeout,
],
'use_aws_shared_config_files' => false,
'retries' => [
'mode' => 'standard',
'max_attempts' => $this->retriesMaxAttempts,
],
];
if ($this->params['s3-accelerate']) {
$options['use_accelerate_endpoint'] = true;
} else {
$options['endpoint'] = $base_url;
}
if (isset($this->params['request_checksum_calculation'])) {
$options['request_checksum_calculation'] = $this->params['request_checksum_calculation'];
} else {
$options['request_checksum_calculation'] = 'when_required';
}
if (isset($this->params['response_checksum_validation'])) {
$options['response_checksum_validation'] = $this->params['response_checksum_validation'];
} else {
$options['response_checksum_validation'] = 'when_required';
}
if ($this->getProxy()) {
$options['http']['proxy'] = $this->getProxy();
}
if (isset($this->params['legacy_auth']) && $this->params['legacy_auth']) {
$options['signature_version'] = 'v2';
}
$this->connection = new S3Client($options);
$this->addDeleteObjectsContentMd5Middleware();
try {
$logger = Server::get(LoggerInterface::class);
if (!$this->connection::isBucketDnsCompatible($this->bucket)) {
$logger->debug('Bucket "' . $this->bucket . '" This bucket name is not dns compatible, it may contain invalid characters.',
['app' => 'objectstore']);
}
if ($this->params['verify_bucket_exists']) {
$cacheKey = $this->params['hostname'] . $this->bucket;
$exist = $this->existingBucketsCache->get($cacheKey) === 1;
if (!$exist) {
if (!$this->connection->doesBucketExist($this->bucket)) {
try {
$logger->info('Bucket "' . $this->bucket . '" does not exist - creating it.', ['app' => 'objectstore']);
if (!$this->connection::isBucketDnsCompatible($this->bucket)) {
throw new StorageNotAvailableException('The bucket will not be created because the name is not dns compatible, please correct it: ' . $this->bucket);
}
$this->connection->createBucket(['Bucket' => $this->bucket]);
Server::get(IEventDispatcher::class)
->dispatchTyped(new BucketCreatedEvent(
$this->bucket,
$options['endpoint'],
$options['region'],
$options['version']
));
$this->testTimeout();
} catch (S3Exception $e) {
$logger->debug('Invalid remote storage.', [
'exception' => $e,
'app' => 'objectstore',
]);
if ($e->getAwsErrorCode() !== 'BucketAlreadyOwnedByYou') {
throw new StorageNotAvailableException('Creation of bucket "' . $this->bucket . '" failed. ' . $e->getMessage());
}
}
}
$this->existingBucketsCache->set($cacheKey, 1);
}
}
// google cloud's s3 compatibility doesn't like the EncodingType parameter
if (strpos($base_url, 'storage.googleapis.com')) {
$this->connection->getHandlerList()->remove('s3.auto_encode');
}
} catch (S3Exception $e) {
throw new StorageNotAvailableException('S3 service is unable to handle request: ' . $e->getMessage());
}
return $this->connection;
}
/**
* when running the tests wait to let the buckets catch up
*/
private function testTimeout() {
if ($this->test) {
sleep($this->timeout);
}
}
/**
* Add middleware to inject Content-MD5 header for DeleteObjects operations
*
* AWS SDK PHP v3.339.0+ stopped generating the Content-MD5 header for DeleteObjects operations.
* However, this is still required by the `bt-blue.com` S3 provider.
* This middleware automatically calculates and adds the header to comply with
* AWS S3 API requirements.
*
* @see https://github.com/aws/aws-sdk-php/issues/3068
*/
private function addDeleteObjectsContentMd5Middleware(): void {
if ($this->connection === null) {
return;
}
$handlerList = $this->connection->getHandlerList();
$handlerList->appendBuild(
Middleware::mapRequest(static function (RequestInterface $request): RequestInterface {
// Only add Content-MD5 for DeleteObjects operations
if ($request->getUri()->getQuery() !== 'delete') {
return $request;
}
// Calculate MD5 of request body and add Content-MD5 header
if (!$request->hasHeader('Content-MD5')) {
$body = $request->getBody();
$contentMd5 = base64_encode(Utils::hash($body, 'md5', true));
return $request->withHeader('Content-MD5', $contentMd5);
}
return $request;
})
);
}
public static function legacySignatureProvider($version, $service, $region) {
switch ($version) {
case 'v2':
case 's3':
return new S3Signature();
default:
return null;
}
}
/**
* This function creates a credential provider based on user parameter file
*/
protected function paramCredentialProvider(): callable {
return function () {
$key = empty($this->params['key']) ? null : $this->params['key'];
$secret = empty($this->params['secret']) ? null : $this->params['secret'];
$sessionToken = empty($this->params['session_token']) ? null : $this->params['session_token'];
if ($key && $secret) {
return Create::promiseFor(
// a null sessionToken match the default signature of the constructor
new Credentials($key, $secret, $sessionToken)
);
}
$msg = 'Could not find parameters set for credentials in config file.';
return new RejectedPromise(new CredentialsException($msg));
};
}
protected function getCertificateBundlePath(): ?string {
if ((int)($this->params['use_nextcloud_bundle'] ?? '0')) {
/** @var ICertificateManager $certManager */
$certManager = Server::get(ICertificateManager::class);
// since we store the certificate bundles on the primary storage, we can't get the bundle while setting up the primary storage
if (!isset($this->params['primary_storage'])) {
return $certManager->getAbsoluteBundlePath();
} else {
return $certManager->getDefaultCertificatesBundlePath();
}
} else {
return null;
}
}
protected function getSSECKey(): ?string {
if (isset($this->params['sse_c_key']) && !empty($this->params['sse_c_key'])) {
return $this->params['sse_c_key'];
}
return null;
}
protected function getSSECParameters(bool $copy = false): array {
$key = $this->getSSECKey();
if ($key === null) {
return [];
}
$rawKey = base64_decode($key);
if ($copy) {
return [
'CopySourceSSECustomerAlgorithm' => 'AES256',
'CopySourceSSECustomerKey' => $rawKey,
'CopySourceSSECustomerKeyMD5' => md5($rawKey, true)
];
}
return [
'SSECustomerAlgorithm' => 'AES256',
'SSECustomerKey' => $rawKey,
'SSECustomerKeyMD5' => md5($rawKey, true)
];
}
public function isUsePresignedUrl(): bool {
return $this->usePresignedUrl;
}
}