001/*
002 * (C) Copyright 2011-2014 Nuxeo SA (http://nuxeo.com/) and contributors.
003 *
004 * All rights reserved. This program and the accompanying materials
005 * are made available under the terms of the GNU Lesser General Public License
006 * (LGPL) version 2.1 which accompanies this distribution, and is available at
007 * http://www.gnu.org/licenses/lgpl.html
008 *
009 * This library is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
012 * Lesser General Public License for more details.
013 *
014 * Contributors:
015 *     Mathieu Guillaume
016 *     Florent Guillaume
017 */
018package org.nuxeo.ecm.core.storage.sql;
019
020import static org.apache.commons.lang.StringUtils.isBlank;
021import static org.apache.commons.lang.StringUtils.isNotBlank;
022
023import java.io.File;
024import java.io.FileInputStream;
025import java.io.IOException;
026import java.net.URI;
027import java.net.URISyntaxException;
028import java.net.URL;
029import java.security.GeneralSecurityException;
030import java.security.KeyPair;
031import java.security.KeyStore;
032import java.security.PrivateKey;
033import java.security.PublicKey;
034import java.security.cert.Certificate;
035import java.util.Date;
036import java.util.HashSet;
037import java.util.Map;
038import java.util.Set;
039import java.util.regex.Pattern;
040
041import javax.servlet.http.HttpServletRequest;
042
043import org.apache.commons.lang.StringUtils;
044import org.apache.commons.logging.Log;
045import org.apache.commons.logging.LogFactory;
046import org.nuxeo.common.Environment;
047import org.nuxeo.common.utils.RFC2231;
048import org.nuxeo.ecm.core.api.Blob;
049import org.nuxeo.ecm.core.blob.BlobManager.BlobInfo;
050import org.nuxeo.ecm.core.blob.BlobManager.UsageHint;
051import org.nuxeo.ecm.core.blob.BlobProvider;
052import org.nuxeo.ecm.core.blob.ManagedBlob;
053import org.nuxeo.ecm.core.blob.binary.BinaryBlobProvider;
054import org.nuxeo.ecm.core.blob.binary.BinaryGarbageCollector;
055import org.nuxeo.ecm.core.blob.binary.BinaryManagerStatus;
056import org.nuxeo.ecm.core.blob.binary.CachingBinaryManager;
057import org.nuxeo.ecm.core.blob.binary.FileStorage;
058import org.nuxeo.ecm.core.io.download.DownloadHelper;
059import org.nuxeo.ecm.core.model.Document;
060import org.nuxeo.runtime.api.Framework;
061
062import com.amazonaws.AmazonClientException;
063import com.amazonaws.AmazonServiceException;
064import com.amazonaws.ClientConfiguration;
065import com.amazonaws.HttpMethod;
066import com.amazonaws.auth.AWSCredentialsProvider;
067import com.amazonaws.auth.InstanceProfileCredentialsProvider;
068import com.amazonaws.services.s3.AmazonS3;
069import com.amazonaws.services.s3.AmazonS3Client;
070import com.amazonaws.services.s3.AmazonS3EncryptionClient;
071import com.amazonaws.services.s3.internal.ServiceUtils;
072import com.amazonaws.services.s3.model.CannedAccessControlList;
073import com.amazonaws.services.s3.model.CryptoConfiguration;
074import com.amazonaws.services.s3.model.EncryptedPutObjectRequest;
075import com.amazonaws.services.s3.model.EncryptionMaterials;
076import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
077import com.amazonaws.services.s3.model.GetObjectRequest;
078import com.amazonaws.services.s3.model.ObjectListing;
079import com.amazonaws.services.s3.model.ObjectMetadata;
080import com.amazonaws.services.s3.model.PutObjectRequest;
081import com.amazonaws.services.s3.model.S3ObjectSummary;
082import com.amazonaws.services.s3.model.StaticEncryptionMaterialsProvider;
083import com.amazonaws.services.s3.transfer.TransferManager;
084import com.amazonaws.services.s3.transfer.Upload;
085import com.amazonaws.services.s3.transfer.model.UploadResult;
086import com.google.common.base.Objects;
087
088/**
089 * A Binary Manager that stores binaries as S3 BLOBs
090 * <p>
091 * The BLOBs are cached locally on first access for efficiency.
092 * <p>
093 * Because the BLOB length can be accessed independently of the binary stream, it is also cached in a simple text file
094 * if accessed before the stream.
095 */
096public class S3BinaryManager extends CachingBinaryManager implements BlobProvider {
097
098    private static final String MD5 = "MD5"; // must be MD5 for Etag
099
100    @Override
101    protected String getDefaultDigestAlgorithm() {
102        return MD5;
103    }
104
105    private static final Log log = LogFactory.getLog(S3BinaryManager.class);
106
107    public static final String BUCKET_NAME_KEY = "nuxeo.s3storage.bucket";
108
109    public static final String BUCKET_PREFIX_KEY = "nuxeo.s3storage.bucket.prefix";
110
111    public static final String BUCKET_REGION_KEY = "nuxeo.s3storage.region";
112
113    public static final String DEFAULT_BUCKET_REGION = null; // US East
114
115    public static final String AWS_ID_KEY = "nuxeo.s3storage.awsid";
116
117    public static final String AWS_ID_ENV_KEY = "AWS_ACCESS_KEY_ID";
118
119    public static final String AWS_SECRET_KEY = "nuxeo.s3storage.awssecret";
120
121    public static final String AWS_SECRET_ENV_KEY = "AWS_SECRET_ACCESS_KEY";
122
123    public static final String CACHE_SIZE_KEY = "nuxeo.s3storage.cachesize";
124
125    public static final String DEFAULT_CACHE_SIZE = "100 MB";
126
127    /** AWS ClientConfiguration default 50 */
128    public static final String CONNECTION_MAX_KEY = "nuxeo.s3storage.connection.max";
129
130    /** AWS ClientConfiguration default 3 (with exponential backoff) */
131    public static final String CONNECTION_RETRY_KEY = "nuxeo.s3storage.connection.retry";
132
133    /** AWS ClientConfiguration default 50*1000 = 50s */
134    public static final String CONNECTION_TIMEOUT_KEY = "nuxeo.s3storage.connection.timeout";
135
136    /** AWS ClientConfiguration default 50*1000 = 50s */
137    public static final String SOCKET_TIMEOUT_KEY = "nuxeo.s3storage.socket.timeout";
138
139    public static final String KEYSTORE_FILE_KEY = "nuxeo.s3storage.crypt.keystore.file";
140
141    public static final String KEYSTORE_PASS_KEY = "nuxeo.s3storage.crypt.keystore.password";
142
143    public static final String PRIVKEY_ALIAS_KEY = "nuxeo.s3storage.crypt.key.alias";
144
145    public static final String PRIVKEY_PASS_KEY = "nuxeo.s3storage.crypt.key.password";
146
147    public static final String ENDPOINT_KEY = "nuxeo.s3storage.endpoint";
148
149    public static final String DIRECTDOWNLOAD_KEY = "nuxeo.s3storage.downloadfroms3";
150
151    public static final String DEFAULT_DIRECTDOWNLOAD = "false";
152
153    public static final String DIRECTDOWNLOAD_EXPIRE_KEY = "nuxeo.s3storage.downloadfroms3.expire";
154
155    public static final int DEFAULT_DIRECTDOWNLOAD_EXPIRE = 60 * 60; // 1h
156
157    private static final Pattern MD5_RE = Pattern.compile("(.*/)?[0-9a-f]{32}");
158
159    protected String bucketName;
160
161    protected String bucketNamePrefix;
162
163    protected AWSCredentialsProvider awsCredentialsProvider;
164
165    protected ClientConfiguration clientConfiguration;
166
167    protected EncryptionMaterials encryptionMaterials;
168
169    protected boolean isEncrypted;
170
171    protected CryptoConfiguration cryptoConfiguration;
172
173    protected AmazonS3 amazonS3;
174
175    protected TransferManager transferManager;
176
177    protected boolean directDownload;
178
179    protected int directDownloadExpire;
180
181    @Override
182    public void initialize(String blobProviderId, Map<String, String> properties) throws IOException {
183        super.initialize(blobProviderId, properties);
184
185        // Get settings from the configuration
186        // TODO parse properties too
187        bucketName = Framework.getProperty(BUCKET_NAME_KEY);
188        bucketNamePrefix = Objects.firstNonNull(Framework.getProperty(BUCKET_PREFIX_KEY), StringUtils.EMPTY);
189        String bucketRegion = Framework.getProperty(BUCKET_REGION_KEY);
190        if (isBlank(bucketRegion)) {
191            bucketRegion = DEFAULT_BUCKET_REGION;
192        }
193        String awsID = Framework.getProperty(AWS_ID_KEY);
194        String awsSecret = Framework.getProperty(AWS_SECRET_KEY);
195
196        String proxyHost = Framework.getProperty(Environment.NUXEO_HTTP_PROXY_HOST);
197        String proxyPort = Framework.getProperty(Environment.NUXEO_HTTP_PROXY_PORT);
198        String proxyLogin = Framework.getProperty(Environment.NUXEO_HTTP_PROXY_LOGIN);
199        String proxyPassword = Framework.getProperty(Environment.NUXEO_HTTP_PROXY_PASSWORD);
200
201        String cacheSizeStr = Framework.getProperty(CACHE_SIZE_KEY);
202        if (isBlank(cacheSizeStr)) {
203            cacheSizeStr = DEFAULT_CACHE_SIZE;
204        }
205
206        int maxConnections = getIntProperty(CONNECTION_MAX_KEY);
207        int maxErrorRetry = getIntProperty(CONNECTION_RETRY_KEY);
208        int connectionTimeout = getIntProperty(CONNECTION_TIMEOUT_KEY);
209        int socketTimeout = getIntProperty(SOCKET_TIMEOUT_KEY);
210
211        String keystoreFile = Framework.getProperty(KEYSTORE_FILE_KEY);
212        String keystorePass = Framework.getProperty(KEYSTORE_PASS_KEY);
213        String privkeyAlias = Framework.getProperty(PRIVKEY_ALIAS_KEY);
214        String privkeyPass = Framework.getProperty(PRIVKEY_PASS_KEY);
215        String endpoint = Framework.getProperty(ENDPOINT_KEY);
216
217        // Fallback on default env keys for ID and secret
218        if (isBlank(awsID)) {
219            awsID = System.getenv(AWS_ID_ENV_KEY);
220        }
221        if (isBlank(awsSecret)) {
222            awsSecret = System.getenv(AWS_SECRET_ENV_KEY);
223        }
224
225        if (isBlank(bucketName)) {
226            throw new RuntimeException("Missing conf: " + BUCKET_NAME_KEY);
227        }
228
229        if (!isBlank(bucketNamePrefix) && !bucketNamePrefix.endsWith("/")) {
230            log.warn(String.format("%s %s S3 bucket prefix should end by '/' " + ": added automatically.",
231                    BUCKET_PREFIX_KEY, bucketNamePrefix));
232            bucketNamePrefix += "/";
233        }
234        // set up credentials
235        if (isBlank(awsID) || isBlank(awsSecret)) {
236            awsCredentialsProvider = new InstanceProfileCredentialsProvider();
237            try {
238                awsCredentialsProvider.getCredentials();
239            } catch (AmazonClientException e) {
240                throw new RuntimeException("Missing AWS credentials and no instance role found");
241            }
242        } else {
243            awsCredentialsProvider = new BasicAWSCredentialsProvider(awsID, awsSecret);
244        }
245
246        // set up client configuration
247        clientConfiguration = new ClientConfiguration();
248        if (isNotBlank(proxyHost)) {
249            clientConfiguration.setProxyHost(proxyHost);
250        }
251        if (isNotBlank(proxyPort)) {
252            clientConfiguration.setProxyPort(Integer.parseInt(proxyPort));
253        }
254        if (isNotBlank(proxyLogin)) {
255            clientConfiguration.setProxyUsername(proxyLogin);
256        }
257        if (proxyPassword != null) { // could be blank
258            clientConfiguration.setProxyPassword(proxyPassword);
259        }
260        if (maxConnections > 0) {
261            clientConfiguration.setMaxConnections(maxConnections);
262        }
263        if (maxErrorRetry >= 0) { // 0 is allowed
264            clientConfiguration.setMaxErrorRetry(maxErrorRetry);
265        }
266        if (connectionTimeout >= 0) { // 0 is allowed
267            clientConfiguration.setConnectionTimeout(connectionTimeout);
268        }
269        if (socketTimeout >= 0) { // 0 is allowed
270            clientConfiguration.setSocketTimeout(socketTimeout);
271        }
272
273        // set up encryption
274        encryptionMaterials = null;
275        if (isNotBlank(keystoreFile)) {
276            boolean confok = true;
277            if (keystorePass == null) { // could be blank
278                log.error("Keystore password missing");
279                confok = false;
280            }
281            if (isBlank(privkeyAlias)) {
282                log.error("Key alias missing");
283                confok = false;
284            }
285            if (privkeyPass == null) { // could be blank
286                log.error("Key password missing");
287                confok = false;
288            }
289            if (!confok) {
290                throw new RuntimeException("S3 Crypto configuration incomplete");
291            }
292            try {
293                // Open keystore
294                File ksFile = new File(keystoreFile);
295                FileInputStream ksStream = new FileInputStream(ksFile);
296                KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
297                keystore.load(ksStream, keystorePass.toCharArray());
298                ksStream.close();
299                // Get keypair for alias
300                if (!keystore.isKeyEntry(privkeyAlias)) {
301                    throw new RuntimeException("Alias " + privkeyAlias + " is missing or not a key alias");
302                }
303                PrivateKey privKey = (PrivateKey) keystore.getKey(privkeyAlias, privkeyPass.toCharArray());
304                Certificate cert = keystore.getCertificate(privkeyAlias);
305                PublicKey pubKey = cert.getPublicKey();
306                KeyPair keypair = new KeyPair(pubKey, privKey);
307                // Get encryptionMaterials from keypair
308                encryptionMaterials = new EncryptionMaterials(keypair);
309                cryptoConfiguration = new CryptoConfiguration();
310            } catch (IOException | GeneralSecurityException e) {
311                throw new RuntimeException("Could not read keystore: " + keystoreFile + ", alias: " + privkeyAlias, e);
312            }
313        }
314        isEncrypted = encryptionMaterials != null;
315
316        // Try to create bucket if it doesn't exist
317        if (!isEncrypted) {
318            amazonS3 = new AmazonS3Client(awsCredentialsProvider, clientConfiguration);
319        } else {
320            amazonS3 = new AmazonS3EncryptionClient(awsCredentialsProvider, new StaticEncryptionMaterialsProvider(
321                    encryptionMaterials), clientConfiguration, cryptoConfiguration);
322        }
323        if (isNotBlank(endpoint)) {
324            amazonS3.setEndpoint(endpoint);
325        }
326
327        try {
328            if (!amazonS3.doesBucketExist(bucketName)) {
329                amazonS3.createBucket(bucketName, bucketRegion);
330                amazonS3.setBucketAcl(bucketName, CannedAccessControlList.Private);
331            }
332        } catch (AmazonClientException e) {
333            throw new IOException(e);
334        }
335
336        directDownload = Boolean.parseBoolean(Framework.getProperty(DIRECTDOWNLOAD_KEY, DEFAULT_DIRECTDOWNLOAD));
337        directDownloadExpire = getIntProperty(DIRECTDOWNLOAD_EXPIRE_KEY);
338        if (directDownloadExpire < 0) {
339            directDownloadExpire = DEFAULT_DIRECTDOWNLOAD_EXPIRE;
340        }
341
342        // Create file cache
343        initializeCache(cacheSizeStr, newFileStorage());
344        createGarbageCollector();
345
346        transferManager = new TransferManager(amazonS3);
347        abortOldUploads();
348    }
349
350    @Override
351    public void close() {
352        // this also shuts down the AmazonS3Client
353        transferManager.shutdownNow();
354        super.close();
355    }
356
357    /**
358     * Aborts uploads that crashed and are older than 1 day.
359     *
360     * @since 7.2
361     */
362    protected void abortOldUploads() throws IOException {
363        int oneDay = 1000 * 60 * 60 * 24;
364        try {
365            transferManager.abortMultipartUploads(bucketName, new Date(System.currentTimeMillis() - oneDay));
366        } catch (AmazonClientException e) {
367            throw new IOException("Failed to abort old uploads", e);
368        }
369    }
370
371    /**
372     * Gets an integer framework property, or -1 if undefined.
373     */
374    protected static int getIntProperty(String key) {
375        String s = Framework.getProperty(key);
376        int value = -1;
377        if (!isBlank(s)) {
378            try {
379                value = Integer.parseInt(s.trim());
380            } catch (NumberFormatException e) {
381                log.error("Cannot parse " + key + ": " + s);
382            }
383        }
384        return value;
385    }
386
387    protected void createGarbageCollector() {
388        garbageCollector = new S3BinaryGarbageCollector(this);
389    }
390
391    protected void removeBinary(String digest) {
392        amazonS3.deleteObject(bucketName, digest);
393    }
394
395    protected static boolean isMissingKey(AmazonClientException e) {
396        if (e instanceof AmazonServiceException) {
397            AmazonServiceException ase = (AmazonServiceException) e;
398            return (ase.getStatusCode() == 404) || "NoSuchKey".equals(ase.getErrorCode())
399                    || "Not Found".equals(e.getMessage());
400        }
401        return false;
402    }
403
404    public static boolean isMD5(String digest) {
405        return MD5_RE.matcher(digest).matches();
406    }
407
408    protected FileStorage newFileStorage() {
409        return new S3FileStorage();
410    }
411
412    public class S3FileStorage implements FileStorage {
413
414        @Override
415        public void storeFile(String digest, File file) throws IOException {
416            long t0 = 0;
417            if (log.isDebugEnabled()) {
418                t0 = System.currentTimeMillis();
419                log.debug("storing blob " + digest + " to S3");
420            }
421            String etag;
422            String key = bucketNamePrefix + digest;
423            try {
424                ObjectMetadata metadata = amazonS3.getObjectMetadata(bucketName, key);
425                etag = metadata.getETag();
426                if (log.isDebugEnabled()) {
427                    log.debug("blob " + digest + " is already in S3");
428                }
429            } catch (AmazonClientException e) {
430                if (!isMissingKey(e)) {
431                    throw new IOException(e);
432                }
433                // not already present -> store the blob
434                PutObjectRequest request;
435                if (!isEncrypted) {
436                    request = new PutObjectRequest(bucketName, key, file);
437                } else {
438                    request = new EncryptedPutObjectRequest(bucketName, key, file);
439                }
440                Upload upload = transferManager.upload(request);
441                try {
442                    UploadResult result = upload.waitForUploadResult();
443                    etag = result.getETag();
444                } catch (AmazonClientException ee) {
445                    throw new IOException(ee);
446                } catch (InterruptedException ee) {
447                    // reset interrupted status
448                    Thread.currentThread().interrupt();
449                    // continue interrupt
450                    throw new RuntimeException(ee);
451                } finally {
452                    if (log.isDebugEnabled()) {
453                        long dtms = System.currentTimeMillis() - t0;
454                        log.debug("stored blob " + digest + " to S3 in " + dtms + "ms");
455                    }
456                }
457            }
458            // check transfer went ok
459            if (!isEncrypted && !etag.equals(digest) && !ServiceUtils.isMultipartUploadETag(etag)) {
460                // When the blob is not encrypted by S3, the MD5 remotely
461                // computed by S3 and passed as a Etag should match the locally
462                // computed MD5 digest.
463                // This check cannot be done when encryption is enabled unless
464                // we could replicate that encryption locally just for that
465                // purpose which would add further load and complexity on the
466                // client.
467                throw new IOException("Invalid ETag in S3, ETag=" + etag + " digest=" + digest);
468            }
469        }
470
471        @Override
472        public boolean fetchFile(String digest, File file) throws IOException {
473            long t0 = 0;
474            if (log.isDebugEnabled()) {
475                t0 = System.currentTimeMillis();
476                log.debug("fetching blob " + digest + " from S3");
477            }
478            try {
479
480                ObjectMetadata metadata = amazonS3.getObject(
481                        new GetObjectRequest(bucketName, bucketNamePrefix + digest), file);
482                // check ETag
483                String etag = metadata.getETag();
484                if (!isEncrypted && !etag.equals(digest) && !ServiceUtils.isMultipartUploadETag(etag)) {
485                    log.error("Invalid ETag in S3, ETag=" + etag + " digest=" + digest);
486                    return false;
487                }
488                return true;
489            } catch (AmazonClientException e) {
490                if (!isMissingKey(e)) {
491                    throw new IOException(e);
492                }
493                return false;
494            } finally {
495                if (log.isDebugEnabled()) {
496                    long dtms = System.currentTimeMillis() - t0;
497                    log.debug("fetched blob " + digest + " from S3 in " + dtms + "ms");
498                }
499            }
500
501        }
502
503        @Override
504        public Long fetchLength(String digest) throws IOException {
505            long t0 = 0;
506            if (log.isDebugEnabled()) {
507                t0 = System.currentTimeMillis();
508                log.debug("fetching blob length " + digest + " from S3");
509            }
510            try {
511                ObjectMetadata metadata = amazonS3.getObjectMetadata(bucketName, bucketNamePrefix + digest);
512                // check ETag
513                String etag = metadata.getETag();
514                if (!isEncrypted && !etag.equals(digest) && !ServiceUtils.isMultipartUploadETag(etag)) {
515                    log.error("Invalid ETag in S3, ETag=" + etag + " digest=" + digest);
516                    return null;
517                }
518                return Long.valueOf(metadata.getContentLength());
519            } catch (AmazonClientException e) {
520                if (!isMissingKey(e)) {
521                    throw new IOException(e);
522                }
523                return null;
524            } finally {
525                if (log.isDebugEnabled()) {
526                    long dtms = System.currentTimeMillis() - t0;
527                    log.debug("fetched blob length " + digest + " from S3 in " + dtms + "ms");
528                }
529            }
530        }
531    }
532
533    /**
534     * Garbage collector for S3 binaries that stores the marked (in use) binaries in memory.
535     */
536    public static class S3BinaryGarbageCollector implements BinaryGarbageCollector {
537
538        protected final S3BinaryManager binaryManager;
539
540        protected volatile long startTime;
541
542        protected BinaryManagerStatus status;
543
544        protected Set<String> marked;
545
546        public S3BinaryGarbageCollector(S3BinaryManager binaryManager) {
547            this.binaryManager = binaryManager;
548        }
549
550        @Override
551        public String getId() {
552            return "s3:" + binaryManager.bucketName;
553        }
554
555        @Override
556        public BinaryManagerStatus getStatus() {
557            return status;
558        }
559
560        @Override
561        public boolean isInProgress() {
562            // volatile as this is designed to be called from another thread
563            return startTime != 0;
564        }
565
566        @Override
567        public void start() {
568            if (startTime != 0) {
569                throw new RuntimeException("Already started");
570            }
571            startTime = System.currentTimeMillis();
572            status = new BinaryManagerStatus();
573            marked = new HashSet<>();
574
575            // XXX : we should be able to do better
576            // and only remove the cache entry that will be removed from S3
577            binaryManager.fileCache.clear();
578        }
579
580        @Override
581        public void mark(String digest) {
582            marked.add(digest);
583            // TODO : should clear the specific cache entry
584        }
585
586        @Override
587        public void stop(boolean delete) {
588            if (startTime == 0) {
589                throw new RuntimeException("Not started");
590            }
591
592            try {
593                // list S3 objects in the bucket
594                // record those not marked
595                Set<String> unmarked = new HashSet<>();
596                ObjectListing list = null;
597                do {
598                    if (list == null) {
599                        list = binaryManager.amazonS3.listObjects(binaryManager.bucketName,
600                                binaryManager.bucketNamePrefix);
601                    } else {
602                        list = binaryManager.amazonS3.listNextBatchOfObjects(list);
603                    }
604                    for (S3ObjectSummary summary : list.getObjectSummaries()) {
605                        String digest = summary.getKey();
606                        if (!isMD5(digest)) {
607                            // ignore files that cannot be MD5 digests for
608                            // safety
609                            continue;
610                        }
611                        long length = summary.getSize();
612                        if (marked.contains(digest)) {
613                            status.numBinaries++;
614                            status.sizeBinaries += length;
615                        } else {
616                            status.numBinariesGC++;
617                            status.sizeBinariesGC += length;
618                            // record file to delete
619                            unmarked.add(digest);
620                            marked.remove(digest); // optimize memory
621                        }
622                    }
623                } while (list.isTruncated());
624                marked = null; // help GC
625
626                // delete unmarked objects
627                if (delete) {
628                    for (String digest : unmarked) {
629                        binaryManager.removeBinary(digest);
630                    }
631                }
632
633            } catch (AmazonClientException e) {
634                throw new RuntimeException(e);
635            }
636
637            status.gcDuration = System.currentTimeMillis() - startTime;
638            startTime = 0;
639        }
640    }
641
642    // ******************** BlobProvider ********************
643
644    @Override
645    public Blob readBlob(BlobInfo blobInfo) throws IOException {
646        // just delegate to avoid copy/pasting code
647        return new BinaryBlobProvider(this).readBlob(blobInfo);
648    }
649
650    @Override
651    public String writeBlob(Blob blob, Document doc) throws IOException {
652        // just delegate to avoid copy/pasting code
653        return new BinaryBlobProvider(this).writeBlob(blob, doc);
654    }
655
656    @Override
657    public boolean supportsWrite() {
658        return true;
659    }
660
661    @Override
662    public URI getURI(ManagedBlob blob, UsageHint hint, HttpServletRequest servletRequest) throws IOException {
663        if (hint != UsageHint.DOWNLOAD || !directDownload) {
664            return null;
665        }
666        String digest = blob.getKey();
667        // strip prefix
668        int colon = digest.indexOf(':');
669        if (colon >= 0) {
670            digest = digest.substring(colon + 1);
671        }
672        return getS3URI(digest, blob, servletRequest);
673    }
674
675    protected URI getS3URI(String digest, Blob blob, HttpServletRequest servletRequest) throws IOException {
676        String key = bucketNamePrefix + digest;
677        Date expiration = new Date();
678        expiration.setTime(expiration.getTime() + directDownloadExpire * 1000);
679        GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, key, HttpMethod.GET);
680        request.addRequestParameter("response-content-type", getContentTypeHeader(blob));
681        request.addRequestParameter("response-content-disposition", getContentDispositionHeader(blob, servletRequest));
682        request.setExpiration(expiration);
683        URL url = amazonS3.generatePresignedUrl(request);
684        try {
685            return url.toURI();
686        } catch (URISyntaxException e) {
687            throw new IOException(e);
688        }
689    }
690
691    protected String getContentTypeHeader(Blob blob) {
692        String contentType = blob.getMimeType();
693        String encoding = blob.getEncoding();
694        if (contentType != null && !StringUtils.isBlank(encoding)) {
695            int i = contentType.indexOf(';');
696            if (i >= 0) {
697                contentType = contentType.substring(0, i);
698            }
699            contentType += "; charset=" + encoding;
700        }
701        return contentType;
702    }
703
704    protected String getContentDispositionHeader(Blob blob, HttpServletRequest servletRequest) {
705        if (servletRequest == null) {
706            return RFC2231.encodeContentDisposition(blob.getFilename(), false, null);
707        } else {
708            return DownloadHelper.getRFC2231ContentDisposition(servletRequest, blob.getFilename());
709        }
710    }
711
712}