View Javadoc
1   /**
2    * Copyright (c) 2012-2017, s3auth.com
3    * All rights reserved.
4    *
5    * Redistribution and use in source and binary forms, with or without
6    * modification, are permitted provided that the following conditions
7    * are met: 1) Redistributions of source code must retain the above
8    * copyright notice, this list of conditions and the following
9    * disclaimer. 2) Redistributions in binary form must reproduce the above
10   * copyright notice, this list of conditions and the following
11   * disclaimer in the documentation and/or other materials provided
12   * with the distribution. 3) Neither the name of the s3auth.com nor
13   * the names of its contributors may be used to endorse or promote
14   * products derived from this software without specific prior written
15   * permission.
16   *
17   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
19   * NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
20   * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
21   * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
22   * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23   * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24   * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
25   * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
26   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
28   * OF THE POSSIBILITY OF SUCH DAMAGE.
29   */
30  package com.s3auth.hosts;
31  
32  import com.amazonaws.AmazonClientException;
33  import com.amazonaws.AmazonServiceException;
34  import com.amazonaws.ClientConfiguration;
35  import com.amazonaws.Protocol;
36  import com.amazonaws.auth.BasicAWSCredentials;
37  import com.amazonaws.services.cloudwatch.AmazonCloudWatchAsyncClient;
38  import com.amazonaws.services.cloudwatch.AmazonCloudWatchClient;
39  import com.amazonaws.services.cloudwatch.model.Datapoint;
40  import com.amazonaws.services.cloudwatch.model.Dimension;
41  import com.amazonaws.services.cloudwatch.model.GetMetricStatisticsRequest;
42  import com.amazonaws.services.cloudwatch.model.StandardUnit;
43  import com.amazonaws.services.s3.model.BucketWebsiteConfiguration;
44  import com.jcabi.aspects.Cacheable;
45  import com.jcabi.aspects.Immutable;
46  import com.jcabi.aspects.Loggable;
47  import com.jcabi.aspects.Tv;
48  import com.jcabi.log.Logger;
49  import com.jcabi.manifests.Manifests;
50  import java.io.IOException;
51  import java.net.URI;
52  import java.util.Collection;
53  import java.util.Date;
54  import java.util.LinkedList;
55  import java.util.List;
56  import java.util.concurrent.Executors;
57  import java.util.concurrent.TimeUnit;
58  import javax.validation.constraints.NotNull;
59  import lombok.EqualsAndHashCode;
60  import org.apache.commons.lang3.StringUtils;
61  import org.apache.commons.lang3.time.DateUtils;
62  import org.apache.http.HttpStatus;
63  
64  /**
65   * Default implementation of {@link Host}.
66   *
67   * @author Yegor Bugayenko (yegor@tpc2.com)
68   * @version $Id: b696d4daee96aeb71af2c75e60f351215b7b5287 $
69   * @since 0.0.1
70   * @checkstyle ClassDataAbstractionCoupling (500 lines)
71   */
72  @Immutable
73  @EqualsAndHashCode(of = "bucket")
74  @Loggable(Loggable.DEBUG)
75  @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.ExcessiveImports" })
76  final class DefaultHost implements Host {
77  
78      /**
79       * The suffix index.html.
80       */
81      private static final String SUFFIX = "index.html";
82  
83      /**
84       * Caching Host.CloudWatch instance.
85       */
86      private static final Host.CloudWatch CLOUDWATCH = new Host.CloudWatch() {
87          @Override
88          @Cacheable(lifetime = 1, unit = TimeUnit.HOURS)
89          public AmazonCloudWatchClient get() {
90              return new AmazonCloudWatchAsyncClient(
91                  new BasicAWSCredentials(
92                      Manifests.read("S3Auth-AwsCloudWatchKey"),
93                      Manifests.read("S3Auth-AwsCloudWatchSecret")
94                  ),
95                  new ClientConfiguration().withProtocol(Protocol.HTTP),
96                  Executors.newFixedThreadPool(Tv.FIFTY)
97              );
98          }
99      };
100 
101     /**
102      * The S3 bucket.
103      */
104     private final transient Bucket bucket;
105 
106     /**
107      * Htpasswd file abstraction.
108      */
109     private final transient Htpasswd htpasswd;
110 
111     /**
112      * Holder of host stats.
113      */
114     private final transient Stats statistics;
115 
116     /**
117      * Amazon Cloudwatch Client.
118      */
119     private final transient Host.CloudWatch cloudwatch;
120 
121     /**
122      * Public ctor.
123      * @param bckt The S3 bucket to use
124      */
125     DefaultHost(@NotNull final Bucket bckt) {
126         this(
127             bckt,
128             DefaultHost.CLOUDWATCH
129         );
130     }
131 
132     /**
133      * Ctor for unit tests.
134      * @param bckt The S3 bucket to use
135      * @param cwatch The Amazon Cloudwatch client
136      */
137     DefaultHost(
138         @NotNull final Bucket bckt,
139         @NotNull final Host.CloudWatch cwatch
140     ) {
141         this.bucket = bckt;
142         this.htpasswd = new Htpasswd(this);
143         this.cloudwatch = cwatch;
144         this.statistics = new HostStats(this.bucket.bucket());
145     }
146 
147     @Override
148     public String toString() {
149         return this.bucket.toString();
150     }
151 
152     @Override
153     public void close() throws IOException {
154         // nothing to do
155     }
156 
157     // @checkstyle CyclomaticComplexity (100 lines)
158     // @checkstyle ExecutableStatementCount (100 lines)
159     @Override
160     @NotNull
161     @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
162     @Loggable(value = Loggable.DEBUG, ignore = IOException.class)
163     public Resource fetch(@NotNull final URI uri,
164         @NotNull final Range range, @NotNull final Version version)
165         throws IOException {
166         Resource resource = null;
167         final Collection<String> errors = new LinkedList<String>();
168         final DomainStatsData data = new H2DomainStatsData();
169         for (final DefaultHost.ObjectName name : this.names(uri)) {
170             try {
171                 if (version.list()) {
172                     resource = new ObjectVersionListing(
173                         this.bucket.client(), this.bucket.bucket(), name.get()
174                     );
175                 } else {
176                     resource = new DefaultResource(
177                         this.bucket.client(), this.bucket.bucket(),
178                         name.get(), range, version, data
179                     );
180                 }
181                 break;
182             } catch (final AmazonServiceException ex) {
183                 if (StringUtils.endsWith(name.get(), DefaultHost.SUFFIX)
184                     && "NoSuchKey".equals(ex.getErrorCode())
185                 ) {
186                     resource = new DirectoryListing(
187                         this.bucket.client(), this.bucket.bucket(),
188                         StringUtils.removeEnd(name.get(), DefaultHost.SUFFIX)
189                     );
190                     break;
191                 } else if ("NoSuchBucket".equals(ex.getErrorCode())) {
192                     throw new IOException(
193                         Logger.format(
194                             "The bucket '%s' does not exist.",
195                             this.bucket.bucket()
196                         ),
197                         ex
198                     );
199                 } else if (ex.getStatusCode() >= HttpStatus.SC_BAD_REQUEST
200                     && ex.getStatusCode() < HttpStatus.SC_INTERNAL_SERVER_ERROR
201                 ) {
202                     try {
203                         final BucketWebsiteConfiguration config =
204                             this.bucket.client().getBucketWebsiteConfiguration(
205                                 this.bucket.bucket()
206                             );
207                         if (config != null
208                             && config.getErrorDocument() != null) {
209                             resource = new DefaultResource(
210                                 this.bucket.client(), this.bucket.bucket(),
211                                 config.getErrorDocument(), Range.ENTIRE,
212                                 Version.LATEST, data
213                             );
214                         }
215                     } catch (final AmazonClientException exc) {
216                         // @checkstyle MultipleStringLiterals (7 lines)
217                         errors.add(
218                             String.format("'%s': %s", name, exc.getMessage())
219                         );
220                     }
221                 }
222                 errors.add(String.format("'%s': %s", name, ex.getMessage()));
223             } catch (final AmazonClientException ex) {
224                 errors.add(String.format("'%s': %s", name, ex.getMessage()));
225             }
226         }
227         if (resource == null) {
228             throw new IOException(
229                 Logger.format(
230                     "failed to fetch %s from '%s' (key=%s): %[list]s",
231                     uri, this.bucket.name(), this.bucket.key(), errors
232                 )
233             );
234         }
235         return resource;
236     }
237 
238     @Override
239     public boolean isHidden(@NotNull final URI uri) {
240         return true;
241     }
242 
243     @Override
244     public boolean authorized(@NotNull final String user,
245         @NotNull final String password) throws IOException {
246         final boolean auth;
247         if (user.equals(this.bucket.key())
248             && password.equals(this.bucket.secret())) {
249             auth = true;
250         } else {
251             auth = this.htpasswd.authorized(user, password);
252         }
253         return auth;
254     }
255 
256     @Override
257     public String syslog() {
258         return this.bucket.syslog();
259     }
260 
261     @Override
262     public Stats stats() {
263         return this.statistics;
264     }
265 
266     /**
267      * Convert URI to all possible S3 object names (in order of importance).
268      * @param uri The URI
269      * @return Object names
270      */
271     private Iterable<DefaultHost.ObjectName> names(final URI uri) {
272         final String name = StringUtils.strip(uri.getPath(), "/");
273         final Collection<DefaultHost.ObjectName> names =
274             new LinkedList<DefaultHost.ObjectName>();
275         if (!name.isEmpty()) {
276             names.add(new DefaultHost.Simple(name));
277         }
278         names.add(new DefaultHost.NameWithSuffix(name));
279         return names;
280     }
281 
282     /**
283      * Object name with a suffix from a bucket.
284      */
285     @Loggable(Loggable.DEBUG)
286     private final class NameWithSuffix implements DefaultHost.ObjectName {
287         /**
288          * Original name.
289          */
290         private final transient String origin;
291         /**
292          * Public ctor.
293          * @param name The original name
294          */
295         NameWithSuffix(final String name) {
296             this.origin = name;
297         }
298         @Override
299         public String get() {
300             String suffix = null;
301             try {
302                 final BucketWebsiteConfiguration conf =
303                     DefaultHost.this.bucket.client()
304                         .getBucketWebsiteConfiguration(
305                             DefaultHost.this.bucket.name()
306                         );
307                 if (conf != null) {
308                     suffix = conf.getIndexDocumentSuffix();
309                 }
310             } catch (final AmazonClientException ex) {
311                 suffix = "";
312             }
313             if (suffix == null || suffix.isEmpty()) {
314                 suffix = DefaultHost.SUFFIX;
315             }
316             final StringBuilder text = new StringBuilder(this.origin);
317             if (text.length() > 0) {
318                 text.append('/');
319             }
320             text.append(suffix);
321             return text.toString();
322         }
323         @Override
324         public String toString() {
325             return String.format("%s+suffix", this.origin);
326         }
327     }
328 
329     /**
330      * Object name.
331      */
332     @EqualsAndHashCode(of = "name")
333     private static final class Simple implements DefaultHost.ObjectName {
334         /**
335          * Original name.
336          */
337         private final transient String name;
338         /**
339          * Public ctor.
340          * @param nme The name
341          */
342         Simple(final String nme) {
343             this.name = nme;
344         }
345         @Override
346         public String get() {
347             return this.name;
348         }
349         @Override
350         public String toString() {
351             return this.name;
352         }
353     }
354 
355     /**
356      * Stats for this domain.
357      */
358     @Loggable(Loggable.DEBUG)
359     @EqualsAndHashCode(of = "bucket")
360     private final class HostStats implements Stats {
361         /**
362          * The S3 bucket.
363          */
364         private final transient String bucket;
365         /**
366          * Public ctor.
367          * @param bckt The name of the bucket
368          */
369         public HostStats(final String bckt) {
370             this.bucket = bckt;
371         }
372         @Override
373         @Cacheable(lifetime = Tv.THIRTY, unit = TimeUnit.MINUTES)
374         public long bytesTransferred() {
375             final Date now = new Date();
376             final List<Datapoint> datapoints =
377                 DefaultHost.this.cloudwatch.get().getMetricStatistics(
378                     new GetMetricStatisticsRequest()
379                         .withMetricName("BytesTransferred")
380                         .withNamespace("S3Auth")
381                         .withStatistics("Sum")
382                         .withDimensions(
383                             new Dimension()
384                                 .withName("Bucket")
385                                 .withValue(this.bucket)
386                         )
387                         .withUnit(StandardUnit.Bytes)
388                         .withPeriod((int) TimeUnit.DAYS.toSeconds(Tv.SEVEN))
389                         .withStartTime(DateUtils.addWeeks(now, -1))
390                         .withEndTime(now)
391                 ).getDatapoints();
392             long sum = 0L;
393             for (final Datapoint datapoint : datapoints) {
394                 sum += datapoint.getSum();
395             }
396             return sum;
397         }
398     }
399 
400     /**
401      * Name of an S3 Object, context dependent.
402      */
403     private interface ObjectName {
404         /**
405          * Returns a name of S3 object.
406          * @return The name
407          */
408         String get();
409     }
410 
411 }