001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.db.storage.s3;
029
030import org.opencms.db.storage.CmsStorageBlobNotFoundException;
031import org.opencms.db.storage.CmsStorageException;
032import org.opencms.db.storage.Messages;
033import org.opencms.file.I_CmsFileContentStreamHandler;
034import org.opencms.main.OpenCms;
035import org.opencms.security.CmsDefaultCredentialsResolver;
036import org.opencms.security.I_CmsCredentialsResolver;
037import org.opencms.util.CmsFileUtil;
038
039import java.io.OutputStream;
040import java.net.URI;
041import java.time.Duration;
042import java.time.Instant;
043import java.util.ArrayList;
044import java.util.LinkedHashMap;
045import java.util.List;
046import java.util.Map;
047
048import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
049import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
050import software.amazon.awssdk.core.ResponseBytes;
051import software.amazon.awssdk.core.ResponseInputStream;
052import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
053import software.amazon.awssdk.core.retry.RetryPolicy;
054import software.amazon.awssdk.core.sync.RequestBody;
055import software.amazon.awssdk.http.apache.ApacheHttpClient;
056import software.amazon.awssdk.regions.Region;
057import software.amazon.awssdk.services.s3.S3Client;
058import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
059import software.amazon.awssdk.services.s3.model.Delete;
060import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
061import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
062import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
063import software.amazon.awssdk.services.s3.model.GetObjectRequest;
064import software.amazon.awssdk.services.s3.model.GetObjectResponse;
065import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
066import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
067import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
068import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
069import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
070import software.amazon.awssdk.services.s3.model.MetadataDirective;
071import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
072import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
073import software.amazon.awssdk.services.s3.model.PutObjectRequest;
074import software.amazon.awssdk.services.s3.model.S3Error;
075import software.amazon.awssdk.services.s3.model.S3Exception;
076import software.amazon.awssdk.services.s3.model.S3Object;
077
078/**
079 * S3 client using AWS SDK v2 optimized for a local object storage.<p>
080 *
081 * Should work with most products such as SeaweedFS, MinIO, Garage, Ceph.<p>
082 */
083public class CmsGenericS3Client implements I_CmsS3Client {
084
085    /** Maximum number of keys accepted by the S3 DeleteObjects API. */
086    public static final int MAX_DELETE_OBJECTS = 1000;
087
088    /** Default complete API call timeout in milliseconds. */
089    public static final int DEFAULT_API_CALL_TIMEOUT = CmsS3ClientConfiguration.DEFAULT_API_CALL_TIMEOUT;
090
091    /** Default single API call attempt timeout in milliseconds. */
092    public static final int DEFAULT_API_CALL_ATTEMPT_TIMEOUT = CmsS3ClientConfiguration.DEFAULT_API_CALL_ATTEMPT_TIMEOUT;
093
094    /** Default connection timeout in milliseconds. */
095    public static final int DEFAULT_CONNECTION_TIMEOUT = CmsS3ClientConfiguration.DEFAULT_CONNECTION_TIMEOUT;
096
097    /** Default timeout for acquiring a pooled connection in milliseconds. */
098    public static final int DEFAULT_CONNECTION_ACQUISITION_TIMEOUT = CmsS3ClientConfiguration.DEFAULT_CONNECTION_ACQUISITION_TIMEOUT;
099
100    /** Default maximum number of pooled connections. */
101    public static final int DEFAULT_MAX_CONNECTIONS = CmsS3ClientConfiguration.DEFAULT_MAX_CONNECTIONS;
102
103    /** Default maximum number of retries. */
104    public static final int DEFAULT_MAX_RETRIES = CmsS3ClientConfiguration.DEFAULT_MAX_RETRIES;
105
106    /** Default AWS region. */
107    public static final String DEFAULT_REGION = CmsS3ClientConfiguration.DEFAULT_REGION;
108
109    /** Default socket timeout in milliseconds. */
110    public static final int DEFAULT_SOCKET_TIMEOUT = CmsS3ClientConfiguration.DEFAULT_SOCKET_TIMEOUT;
111
112    /** The S3 client. */
113    private final S3Client m_s3Client;
114
115    /** The client configuration. */
116    private final CmsS3ClientConfiguration m_configuration;
117
118    /**
119     * Creates a new local S3 client.<p>
120     *
121     * @param configuration the S3 client configuration
122     */
123    public CmsGenericS3Client(CmsS3ClientConfiguration configuration) {
124
125        m_configuration = resolveCredentials(configuration);
126        ApacheHttpClient.Builder httpClientBuilder = ApacheHttpClient.builder().connectionTimeout(
127            Duration.ofMillis(m_configuration.getConnectionTimeout())).connectionAcquisitionTimeout(
128                Duration.ofMillis(m_configuration.getConnectionAcquisitionTimeout())).maxConnections(
129                    m_configuration.getMaxConnections()).socketTimeout(
130                        Duration.ofMillis(m_configuration.getSocketTimeout()));
131        ClientOverrideConfiguration overrideConfiguration = ClientOverrideConfiguration.builder().apiCallAttemptTimeout(
132            Duration.ofMillis(m_configuration.getApiCallAttemptTimeout())).apiCallTimeout(
133                Duration.ofMillis(m_configuration.getApiCallTimeout())).retryPolicy(
134                    RetryPolicy.builder().numRetries(m_configuration.getMaxRetries()).build()).build();
135        m_s3Client = S3Client.builder().endpointOverride(URI.create(m_configuration.getEndpoint())).credentialsProvider(
136            StaticCredentialsProvider.create(
137                AwsBasicCredentials.create(m_configuration.getAccessKey(), m_configuration.getSecretKey()))).region(
138                    Region.of(m_configuration.getRegion())).forcePathStyle(
139                        m_configuration.isPathStyle()).httpClientBuilder(httpClientBuilder).overrideConfiguration(
140                            overrideConfiguration).build();
141    }
142
143    /**
144     * Creates a new local S3 client.<p>
145     *
146     * @param endpoint the URL of the S3 server (e.g., "http://localhost:8080")
147     * @param bucketName the bucket name
148     * @param accessKey the access key
149     * @param secretKey the secret key
150     * @param pathStyle whether path-style access should be used
151     */
152    public CmsGenericS3Client(
153        String endpoint,
154        String bucketName,
155        String accessKey,
156        String secretKey,
157        boolean pathStyle) {
158
159        this(CmsS3ClientConfiguration.createDefault(endpoint, bucketName, accessKey, secretKey, pathStyle));
160    }
161
162    /**
163     * Creates a new local S3 client.<p>
164     *
165     * @param endpoint the URL of the S3 server (e.g., "http://localhost:8080")
166     * @param bucketName the bucket name
167     * @param accessKey the access key
168     * @param secretKey the secret key
169     * @param pathStyle whether path-style access should be used
170     * @param connectionTimeout the connection timeout in milliseconds
171     * @param socketTimeout the socket timeout in milliseconds
172     * @param apiCallAttemptTimeout the timeout for a single API call attempt in milliseconds
173     * @param apiCallTimeout the timeout for the complete API call in milliseconds
174     * @param maxRetries the maximum number of retries
175     */
176    public CmsGenericS3Client(
177        String endpoint,
178        String bucketName,
179        String accessKey,
180        String secretKey,
181        boolean pathStyle,
182        int connectionTimeout,
183        int socketTimeout,
184        int apiCallAttemptTimeout,
185        int apiCallTimeout,
186        int maxRetries) {
187
188        this(
189            new CmsS3ClientConfiguration(
190                endpoint,
191                bucketName,
192                accessKey,
193                secretKey,
194                pathStyle,
195                DEFAULT_REGION,
196                connectionTimeout,
197                socketTimeout,
198                apiCallAttemptTimeout,
199                apiCallTimeout,
200                maxRetries));
201    }
202
203    /**
204     * Resolves credentials in the S3 client configuration with the OpenCms credentials resolver.<p>
205     *
206     * @param configuration the original S3 client configuration
207     * @return the S3 client configuration with resolved credentials
208     */
209    public static CmsS3ClientConfiguration resolveCredentials(CmsS3ClientConfiguration configuration) {
210
211        return resolveCredentials(configuration, getCredentialsResolver());
212    }
213
214    /**
215     * Resolves credentials in the S3 client configuration.<p>
216     *
217     * @param configuration the original S3 client configuration
218     * @param credentialsResolver the credentials resolver
219     * @return the S3 client configuration with resolved credentials
220     */
221    public static CmsS3ClientConfiguration resolveCredentials(
222        CmsS3ClientConfiguration configuration,
223        I_CmsCredentialsResolver credentialsResolver) {
224
225        String accessKey = credentialsResolver.resolveCredential(
226            I_CmsCredentialsResolver.S3_ACCESS_KEY,
227            configuration.getAccessKey());
228        String secretKey = credentialsResolver.resolveCredential(
229            I_CmsCredentialsResolver.S3_SECRET_KEY,
230            configuration.getSecretKey());
231        return new CmsS3ClientConfiguration(
232            configuration.getEndpoint(),
233            configuration.getBucketName(),
234            accessKey,
235            secretKey,
236            configuration.isPathStyle(),
237            configuration.getRegion(),
238            configuration.getConnectionTimeout(),
239            configuration.getSocketTimeout(),
240            configuration.getApiCallAttemptTimeout(),
241            configuration.getApiCallTimeout(),
242            configuration.getMaxRetries(),
243            configuration.getMaxConnections(),
244            configuration.getConnectionAcquisitionTimeout());
245    }
246
247    /**
248     * Returns the OpenCms credentials resolver, or the default resolver if OpenCms is not initialized far enough.<p>
249     *
250     * @return the credentials resolver
251     */
252    private static I_CmsCredentialsResolver getCredentialsResolver() {
253
254        try {
255            return OpenCms.getCredentialsResolver();
256        } catch (RuntimeException e) {
257            return new CmsDefaultCredentialsResolver();
258        }
259    }
260
261    @Override
262    public void close() throws Exception {
263
264        m_s3Client.close();
265    }
266
267    @Override
268    public void deleteObject(String key) throws Exception {
269
270        try {
271            DeleteObjectRequest request = DeleteObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
272                key).build();
273            m_s3Client.deleteObject(request);
274        } catch (S3Exception e) {
275            throw createStorageException("DELETE", key, e);
276        } catch (RuntimeException e) {
277            throw createStorageException("DELETE", key, e);
278        }
279    }
280
281    /**
282     * @see org.opencms.db.storage.s3.I_CmsS3Client#deleteObjects(java.util.List)
283     */
284    @Override
285    public CmsS3DeleteResult deleteObjects(List<String> keys) throws Exception {
286
287        if (keys.isEmpty()) {
288            return new CmsS3DeleteResult(keys, java.util.Collections.emptyMap());
289        }
290        if (keys.size() > MAX_DELETE_OBJECTS) {
291            throw new IllegalArgumentException(
292                "S3 multi-object delete supports at most " + MAX_DELETE_OBJECTS + " keys per request.");
293        }
294        List<ObjectIdentifier> objectIdentifiers = new ArrayList<ObjectIdentifier>(keys.size());
295        for (String key : keys) {
296            objectIdentifiers.add(ObjectIdentifier.builder().key(key).build());
297        }
298        try {
299            Delete delete = Delete.builder().objects(objectIdentifiers).quiet(Boolean.FALSE).build();
300            DeleteObjectsRequest request = DeleteObjectsRequest.builder().bucket(
301                m_configuration.getBucketName()).delete(delete).build();
302            DeleteObjectsResponse response = m_s3Client.deleteObjects(request);
303            Map<String, Exception> failures = new LinkedHashMap<String, Exception>();
304            for (S3Error error : response.errors()) {
305                failures.put(
306                    error.key(),
307                    new CmsStorageException(
308                        "S3 multi-object delete failed. operation=DELETE_OBJECTS, endpoint="
309                            + m_configuration.getEndpoint()
310                            + ", bucket="
311                            + m_configuration.getBucketName()
312                            + ", key="
313                            + error.key()
314                            + ", errorCode="
315                            + error.code()
316                            + ", message="
317                            + error.message()));
318            }
319            return new CmsS3DeleteResult(keys, failures);
320        } catch (S3Exception e) {
321            throw createStorageException("DELETE_OBJECTS", null, e);
322        } catch (RuntimeException e) {
323            throw createStorageException("DELETE_OBJECTS", null, e);
324        }
325    }
326
327    @Override
328    public boolean exists(String key) throws Exception {
329
330        try {
331            HeadObjectRequest request = HeadObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
332                key).build();
333            m_s3Client.headObject(request);
334            return true;
335        } catch (NoSuchKeyException e) {
336            return false;
337        } catch (S3Exception e) {
338            if (e.statusCode() == 404) {
339                return false;
340            }
341            throw createStorageException("HEAD", key, e);
342        } catch (RuntimeException e) {
343            throw createStorageException("HEAD", key, e);
344        }
345    }
346
347    @Override
348    public byte[] getObject(String key) throws Exception {
349
350        try {
351            GetObjectRequest request = GetObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
352                key).build();
353            ResponseBytes<GetObjectResponse> objectBytes = m_s3Client.getObjectAsBytes(request);
354            return objectBytes.asByteArray();
355        } catch (NoSuchKeyException e) {
356            throw createBlobNotFoundException("GET", key, e);
357        } catch (S3Exception e) {
358            if (e.statusCode() == 404) {
359                throw createBlobNotFoundException("GET", key, e);
360            }
361            throw createStorageException("GET", key, e);
362        } catch (RuntimeException e) {
363            throw createStorageException("GET", key, e);
364        }
365    }
366
367    @Override
368    public long getObjectLength(String key) throws Exception {
369
370        try {
371            HeadObjectRequest request = HeadObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
372                key).build();
373            return m_s3Client.headObject(request).contentLength();
374        } catch (NoSuchKeyException e) {
375            throw new CmsStorageBlobNotFoundException(key, e);
376        } catch (S3Exception e) {
377            if (e.statusCode() == 404) {
378                throw new CmsStorageBlobNotFoundException(key, e);
379            }
380            throw createStorageException("HEAD", key, e);
381        } catch (RuntimeException e) {
382            throw createStorageException("HEAD", key, e);
383        }
384    }
385
386    @Override
387    public long getObjectLengthIfExists(String key) throws Exception {
388
389        try {
390            HeadObjectRequest request = HeadObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
391                key).build();
392            return m_s3Client.headObject(request).contentLength();
393        } catch (NoSuchKeyException e) {
394            return -1;
395        } catch (S3Exception e) {
396            if (e.statusCode() == 404) {
397                return -1;
398            }
399            throw createStorageException("HEAD", key, e);
400        } catch (RuntimeException e) {
401            throw createStorageException("HEAD", key, e);
402        }
403    }
404
405    /**
406     * @see org.opencms.db.storage.s3.I_CmsS3Client#getObjectMetadata(java.lang.String)
407     */
408    @Override
409    public CmsS3ObjectMetadata getObjectMetadata(String key) throws Exception {
410
411        try {
412            HeadObjectRequest request = HeadObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
413                key).build();
414            HeadObjectResponse response = m_s3Client.headObject(request);
415            return new CmsS3ObjectMetadata(key, response.contentLength(), response.lastModified(), response.eTag());
416        } catch (NoSuchKeyException e) {
417            throw new CmsStorageBlobNotFoundException(key, e);
418        } catch (S3Exception e) {
419            if (e.statusCode() == 404) {
420                throw new CmsStorageBlobNotFoundException(key, e);
421            }
422            throw createStorageException("HEAD", key, e);
423        } catch (RuntimeException e) {
424            throw createStorageException("HEAD", key, e);
425        }
426    }
427
428    @Override
429    public void putObject(String key, byte[] content) throws Exception {
430
431        try {
432            PutObjectRequest request = PutObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
433                key).build();
434            m_s3Client.putObject(request, RequestBody.fromBytes(content));
435        } catch (S3Exception e) {
436            throw createStorageException("PUT", key, e);
437        } catch (RuntimeException e) {
438            throw createStorageException("PUT", key, e);
439        }
440    }
441
442    /**
443     * @see I_CmsS3Client#readObjectFrom(String, I_CmsFileContentStreamHandler)
444     */
445    @Override
446    public void readObjectFrom(String key, I_CmsFileContentStreamHandler handler) throws Exception {
447
448        try {
449            GetObjectRequest request = GetObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
450                key).build();
451            try (ResponseInputStream<GetObjectResponse> in = m_s3Client.getObject(request)) {
452                handler.read(in);
453            }
454        } catch (NoSuchKeyException e) {
455            throw createBlobNotFoundException("GET", key, e);
456        } catch (S3Exception e) {
457            if (e.statusCode() == 404) {
458                throw createBlobNotFoundException("GET", key, e);
459            }
460            throw createStorageException("GET", key, e);
461        } catch (RuntimeException e) {
462            throw createStorageException("GET", key, e);
463        }
464    }
465
466    /**
467     * @see org.opencms.db.storage.s3.I_CmsS3Client#renewObject(java.lang.String, java.lang.String, java.time.Instant)
468     */
469    @Override
470    public boolean renewObject(String key, String expectedRevision, Instant renewalTime) throws Exception {
471
472        try {
473            // S3 requires REPLACE when copying an object onto itself; image cache writes no custom metadata.
474            CopyObjectRequest request = CopyObjectRequest.builder().sourceBucket(
475                m_configuration.getBucketName()).sourceKey(key).destinationBucket(
476                    m_configuration.getBucketName()).destinationKey(key).copySourceIfMatch(
477                        expectedRevision).metadataDirective(MetadataDirective.REPLACE).build();
478            m_s3Client.copyObject(request);
479            return true;
480        } catch (NoSuchKeyException e) {
481            return false;
482        } catch (S3Exception e) {
483            if ((e.statusCode() == 404) || (e.statusCode() == 412)) {
484                return false;
485            }
486            throw createStorageException("RENEW", key, e);
487        } catch (RuntimeException e) {
488            throw createStorageException("RENEW", key, e);
489        }
490    }
491
492    @Override
493    public void validateBucketAccess() throws Exception {
494
495        try {
496            HeadBucketRequest request = HeadBucketRequest.builder().bucket(m_configuration.getBucketName()).build();
497            m_s3Client.headBucket(request);
498        } catch (S3Exception e) {
499            throw createStorageException("HEAD_BUCKET", null, e);
500        } catch (RuntimeException e) {
501            throw createStorageException("HEAD_BUCKET", null, e);
502        }
503    }
504
505    @Override
506    public void visitObjectKeys(I_CmsS3ObjectKeyVisitor visitor) throws Exception {
507
508        visitObjects(null, metadata -> visitor.visit(metadata.getKey()));
509    }
510
511    @Override
512    public void visitObjects(I_CmsS3ObjectVisitor visitor) throws Exception {
513
514        visitObjects(null, metadata -> visitor.visit(metadata.getKey(), metadata.getLength()));
515    }
516
517    /**
518     * @see org.opencms.db.storage.s3.I_CmsS3Client#visitObjects(java.lang.String, org.opencms.db.storage.s3.I_CmsS3Client.I_CmsS3ObjectMetadataVisitor)
519     */
520    @Override
521    public void visitObjects(String prefix, I_CmsS3ObjectMetadataVisitor visitor) throws Exception {
522
523        try {
524            ListObjectsV2Request.Builder requestBuilder = ListObjectsV2Request.builder().bucket(
525                m_configuration.getBucketName());
526            if (prefix != null) {
527                requestBuilder.prefix(prefix);
528            }
529            ListObjectsV2Request request = requestBuilder.build();
530            for (ListObjectsV2Response page : m_s3Client.listObjectsV2Paginator(request)) {
531                for (S3Object object : page.contents()) {
532                    visitor.visit(
533                        new CmsS3ObjectMetadata(object.key(), object.size(), object.lastModified(), object.eTag()));
534                }
535            }
536        } catch (S3Exception e) {
537            throw createStorageException("LIST", null, e);
538        } catch (RuntimeException e) {
539            throw createStorageException("LIST", null, e);
540        }
541    }
542
543    /**
544     * @see I_CmsS3Client#writeObjectRangeTo(String, long, long, OutputStream)
545     */
546    @Override
547    public void writeObjectRangeTo(String key, long start, long length, OutputStream out) throws Exception {
548
549        writeObjectRangeToWithMetadata(key, start, length, out);
550    }
551
552    /**
553     * @see I_CmsS3Client#writeObjectRangeToWithMetadata(String, long, long, OutputStream)
554     */
555    @Override
556    public CmsS3ObjectMetadata writeObjectRangeToWithMetadata(String key, long start, long length, OutputStream out)
557    throws Exception {
558
559        if ((start < 0) || (length < 1) || ((Long.MAX_VALUE - start) < length)) {
560            throw new IllegalArgumentException("Invalid byte range: start=" + start + ", length=" + length);
561        }
562        long end = start + length - 1;
563        try {
564            GetObjectRequest request = GetObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
565                key).range("bytes=" + start + "-" + end).build();
566            try (ResponseInputStream<GetObjectResponse> in = m_s3Client.getObject(request)) {
567                CmsFileUtil.copy(in, out);
568                GetObjectResponse response = in.response();
569                return new CmsS3ObjectMetadata(key, response.contentLength(), response.lastModified(), response.eTag());
570            }
571        } catch (NoSuchKeyException e) {
572            throw createBlobNotFoundException("GET", key, e);
573        } catch (S3Exception e) {
574            if (e.statusCode() == 404) {
575                throw createBlobNotFoundException("GET", key, e);
576            }
577            throw createStorageException("GET", key, e);
578        } catch (RuntimeException e) {
579            throw createStorageException("GET", key, e);
580        }
581    }
582
583    @Override
584    public void writeObjectTo(String key, OutputStream out) throws Exception {
585
586        writeObjectToWithMetadata(key, out);
587    }
588
589    /**
590     * @see I_CmsS3Client#writeObjectToWithMetadata(String, OutputStream)
591     */
592    @Override
593    public CmsS3ObjectMetadata writeObjectToWithMetadata(String key, OutputStream out) throws Exception {
594
595        try {
596            GetObjectRequest request = GetObjectRequest.builder().bucket(m_configuration.getBucketName()).key(
597                key).build();
598            try (ResponseInputStream<GetObjectResponse> in = m_s3Client.getObject(request)) {
599                CmsFileUtil.copy(in, out);
600                GetObjectResponse response = in.response();
601                return new CmsS3ObjectMetadata(key, response.contentLength(), response.lastModified(), response.eTag());
602            }
603        } catch (NoSuchKeyException e) {
604            throw createBlobNotFoundException("GET", key, e);
605        } catch (S3Exception e) {
606            if (e.statusCode() == 404) {
607                throw createBlobNotFoundException("GET", key, e);
608            }
609            throw createStorageException("GET", key, e);
610        } catch (RuntimeException e) {
611            throw createStorageException("GET", key, e);
612        }
613    }
614
615    /**
616     * Creates a missing blob exception with S3 context.<p>
617     *
618     * @param operation the S3 operation
619     * @param key the S3 key
620     * @param cause the cause
621     * @return the missing blob exception
622     */
623    private CmsStorageBlobNotFoundException createBlobNotFoundException(String operation, String key, Exception cause) {
624
625        return new CmsStorageBlobNotFoundException(
626            Messages.get().getBundle().key(Messages.ERR_STORAGE_BLOB_MISSING_2, m_configuration.getBucketName(), key)
627                + " "
628                + createContext(operation, key, cause),
629            cause);
630    }
631
632    /**
633     * Creates the S3 diagnostic context.<p>
634     *
635     * @param operation the S3 operation
636     * @param key the S3 key
637     * @param cause the cause
638     * @return the context
639     */
640    private String createContext(String operation, String key, Exception cause) {
641
642        StringBuilder result = new StringBuilder(192);
643        result.append("operation=").append(operation);
644        result.append(", endpoint=").append(m_configuration.getEndpoint());
645        result.append(", bucket=").append(m_configuration.getBucketName());
646        if (key != null) {
647            result.append(", key=").append(key);
648        }
649        result.append(", region=").append(m_configuration.getRegion());
650        result.append(", pathStyle=").append(m_configuration.isPathStyle());
651        if (cause instanceof S3Exception) {
652            S3Exception s3Exception = (S3Exception)cause;
653            result.append(", statusCode=").append(s3Exception.statusCode());
654            if (s3Exception.awsErrorDetails() != null) {
655                result.append(", errorCode=").append(s3Exception.awsErrorDetails().errorCode());
656            }
657            result.append(", requestId=").append(s3Exception.requestId());
658        }
659        return result.toString();
660    }
661
662    /**
663     * Creates an exception with S3 context.<p>
664     *
665     * @param operation the S3 operation
666     * @param key the S3 key
667     * @param cause the cause
668     * @return the storage exception
669     */
670    private CmsStorageException createStorageException(String operation, String key, Exception cause) {
671
672        return new CmsStorageException("S3 storage operation failed. " + createContext(operation, key, cause), cause);
673    }
674}