View Javadoc

1   package org.kuali.maven.mojo.s3;
2   
3   import java.io.ByteArrayInputStream;
4   import java.io.IOException;
5   import java.io.InputStream;
6   import java.text.SimpleDateFormat;
7   import java.util.Collection;
8   import java.util.Date;
9   import java.util.List;
10  import java.util.Map;
11  import java.util.TimeZone;
12  
13  import org.apache.commons.beanutils.BeanUtils;
14  import org.apache.commons.io.IOUtils;
15  import org.apache.commons.lang.StringUtils;
16  import org.apache.maven.plugin.MojoExecutionException;
17  import org.apache.maven.plugin.MojoFailureException;
18  import org.apache.maven.plugin.descriptor.PluginDescriptor;
19  import org.kuali.maven.common.UrlBuilder;
20  
21  import com.amazonaws.auth.AWSCredentials;
22  import com.amazonaws.services.s3.AmazonS3Client;
23  import com.amazonaws.services.s3.model.CannedAccessControlList;
24  import com.amazonaws.services.s3.model.CopyObjectRequest;
25  import com.amazonaws.services.s3.model.ListObjectsRequest;
26  import com.amazonaws.services.s3.model.ObjectListing;
27  import com.amazonaws.services.s3.model.ObjectMetadata;
28  import com.amazonaws.services.s3.model.PutObjectRequest;
29  import com.amazonaws.services.s3.model.S3Object;
30  import com.amazonaws.services.s3.model.S3ObjectSummary;
31  
32  /**
33   * <p>
34   * This mojo updates a bucket serving as an origin for a Cloud Front distribution. It generates an html directory
35   * listing for each "directory" in the bucket and stores the html under a key in the bucket such that a regular http
36   * request for a directory returns the html instead of the XML for "object does not exist" Amazon would normally return.
37   * For example: The url "http://www.mybucket.com/foo/bar" returns an html page containing a listing of all the files and
38   * directories under "foo/bar" in the bucket.
39   * </p>
40   * <p>
41   * If a directory contains an object with a key that is the same as the default object, the plugin copies the object to
42   * a key representing the directory structure. For example, the url "http://www.mybucket.com/foo/bar/index.html"
43   * represents an object in an S3 bucket under the key "foo/bar/index.html". This plugin will copy the object from the
44   * key "foo/bar/index.html" to the key "foo/bar/". This causes the url "http://www.mybucket.com/foo/bar/" to return the
45   * same content as the url "http://www.mybucket.com/foo/bar/index.html"
46   * </p>
47   * <p>
48   * It also generates an html directory listing at the root of the bucket hierarchy and places that html into the bucket
49   * as the default object, unless a default object already exists.
50   * </p>
51   *
52   * @goal updateoriginbucket
53   * @aggregator
54   */
55  public class UpdateOriginBucketMojo extends S3Mojo {
56  
57      private static final String S3_INDEX_METADATA_KEY = "maven-cloudfront-plugin-index";
58      private static final String S3_INDEX_CONTENT_TYPE = "text/html";
59      CloudFrontHtmlGenerator generator;
60      S3DataConverter converter;
61  
62      /**
63       * The groupId for the organization
64       *
65       * @parameter expression="${organizationGroupId}" default-value="org.kuali"
66       */
67      private String organizationGroupId;
68  
69      /**
70       * This controls the caching behavior for CloudFront. By default, CloudFront edge locations cache content from an S3
71       * bucket for 24 hours. That interval is shortened to 1 hour for the html indexes generated by this plugin.
72       *
73       * @parameter expression="${cacheControl}" default-value="max-age=3600, must-revalidate"
74       */
75      private String cacheControl;
76  
77      /**
78       * If true, the complete hierarchy underneath <code>prefix</code> will be recursively updated. If false, only the
79       * directory corresponding to the prefix will be updated along with the path back to the root of the bucket
80       *
81       * @parameter expression="${recurse}" default-value="true"
82       */
83      private boolean recurse;
84  
85      /**
86       * If true, "foo/bar/index.html" will get copied to "foo/bar/"
87       *
88       * @parameter expression="${copyDefaultObjectWithDelimiter}" default-value="true"
89       */
90      private boolean copyDefaultObjectWithDelimiter;
91  
92      /**
93       * If true, "foo/bar/index.html" will get copied to "foo/bar". This is defaulted to false because the relative
94       * pathing in the html generated by the maven-site-plugin does not render correctly from a url without the trailing
95       * slash.
96       *
97       * @parameter expression="${copyDefaultObjectWithoutDelimiter}" default-value="false"
98       */
99      private boolean copyDefaultObjectWithoutDelimiter;
100 
101     /**
102      * The stylesheet to use for the directory listing
103      *
104      * @parameter expression="${css}" default-value="http://s3browse.ks.kuali.org/css/style.css"
105      */
106     private String css;
107 
108     /**
109      * Image representing a file
110      *
111      * @parameter expression="${fileImage}" default-value="http://s3browse.ks.kuali.org/images/page_white.png"
112      */
113     private String fileImage;
114 
115     /**
116      * Image representing a directory
117      *
118      * @parameter expression="${directoryImage}" default-value="http://s3browse.ks.kuali.org/images/folder.png"
119      */
120     private String directoryImage;
121 
122     /**
123      * When displaying the last modified timestamp, use this timezone
124      *
125      * @parameter expression="${timezone}" default-value="GMT"
126      */
127     private String timezone;
128 
129     /**
130      * When displaying the last modified timestamp use this format
131      *
132      * @parameter expression="${dateFormat}" default-value="EEE, dd MMM yyyy HH:mm:ss z"
133      */
134     private String dateFormat;
135 
136     /**
137      * The key containing the default object for the Cloud Front distribution. If this object already exists, the plugin
138      * will not modify it. If it does not exist, this plugin will generate an html directory listing and place it into
139      * the bucket under this key.
140      *
141      * @parameter expression="${defaultObject}" default-value="index.html";
142      */
143     private String defaultObject;
144 
145     /**
146      * The html for browsing a directory will be created under this key
147      *
148      * @parameter expression="${browseHtml}" default-value="browse.html";
149      */
150     private String browseHtml;
151 
152     @Override
153     public void executeMojo() throws MojoExecutionException, MojoFailureException {
154         try {
155             getLog().info("Updating S3 bucket - " + getBucket());
156             S3BucketContext context = getS3BucketContext();
157             generator = new CloudFrontHtmlGenerator(context);
158             converter = new S3DataConverter(context);
159             converter.setBrowseHtml(getBrowseHtml());
160             if (isRecurse()) {
161                 getLog().info("Recursing into " + getPrefix());
162                 recurse(context, getPrefix());
163             }
164             getLog().info("Updating hierarchy underneath " + getPrefix());
165             goUpTheChain(context, getPrefix());
166         } catch (Exception e) {
167             throw new MojoExecutionException("Unexpected error: ", e);
168         }
169     }
170 
171     protected void goUpTheChain(final S3BucketContext context, final String startingPrefix) throws IOException {
172         handleRoot(getS3PrefixContext(context, null));
173 
174         if (StringUtils.isEmpty(startingPrefix)) {
175             return;
176         }
177 
178         String[] prefixes = StringUtils.splitByWholeSeparator(startingPrefix, context.getDelimiter());
179         if (prefixes.length == 1) {
180             return;
181         }
182         String newPrefix = "";
183         for (int i = 0; i < prefixes.length - 2; i++) {
184             newPrefix += prefixes[i] + context.getDelimiter();
185             updateDirectory(getS3PrefixContext(context, newPrefix));
186         }
187     }
188 
189     protected void updatePrefix() {
190         UrlBuilder builder = new UrlBuilder();
191         String sitePath = builder.getSitePath(getProject(), getOrganizationGroupId());
192         String s = getPrefix();
193         if (StringUtils.isEmpty(s)) {
194             s = sitePath + "/" + getProject().getVersion() + "/";
195         }
196         if (!s.endsWith(getDelimiter())) {
197             s = s + getDelimiter();
198         }
199         setPrefix(s);
200     }
201 
202     protected S3BucketContext getS3BucketContext() throws MojoExecutionException {
203         updateCredentials();
204         validateCredentials();
205         AWSCredentials credentials = getCredentials();
206         AmazonS3Client client = new AmazonS3Client(credentials);
207         updatePrefix();
208         S3BucketContext context = new S3BucketContext();
209         try {
210             BeanUtils.copyProperties(context, this);
211         } catch (Exception e) {
212             throw new MojoExecutionException("Error copying properties", e);
213         }
214         context.setClient(client);
215         context.setLastModifiedDateFormatter(getLastModifiedDateFormatter());
216         context.setAbout(getAbout());
217         return context;
218     }
219 
220     /**
221      * Create a PutObjectRequest for some html generated by this mojo. The PutObjectRequest sets the content type to
222      * S3_INDEX_CONTENT_TYPE, sets the ACL to PublicRead, and adds some custom metadata so we can positively identify it
223      * as an object created by this plugin
224      */
225     protected PutObjectRequest getPutIndexObjectRequest(final String html, final String key) throws IOException {
226         InputStream in = new ByteArrayInputStream(html.getBytes());
227         ObjectMetadata om = new ObjectMetadata();
228         om.setCacheControl(getCacheControl());
229         String contentType = S3_INDEX_CONTENT_TYPE;
230         om.setContentType(contentType);
231         om.setContentLength(html.length());
232         om.addUserMetadata(S3_INDEX_METADATA_KEY, "true");
233         PutObjectRequest request = new PutObjectRequest(getBucket(), key, in, om);
234         request.setCannedAcl(CannedAccessControlList.PublicRead);
235         return request;
236     }
237 
238     /**
239      * Return a SimpleDateFormat object initialized with the date format and timezone supplied to the mojo
240      */
241     protected SimpleDateFormat getLastModifiedDateFormatter() {
242         SimpleDateFormat sdf = new SimpleDateFormat(getDateFormat());
243         sdf.setTimeZone(TimeZone.getTimeZone(getTimezone()));
244         return sdf;
245     }
246 
247     /**
248      * Return true if the Collection is null or contains no entries, false otherwise
249      */
250     protected boolean isEmpty(final Collection<?> c) {
251         return c == null || c.size() == 0;
252     }
253 
254     /**
255      * Show some text about this plugin
256      */
257     protected String getAbout() {
258         String date = getLastModifiedDateFormatter().format(new Date());
259         PluginDescriptor descriptor = (PluginDescriptor) this.getPluginContext().get("pluginDescriptor");
260         if (descriptor == null) {
261             // Maven 2.2.1 is returning a null descriptor
262             return "Listing generated by the maven-cloudfront-plugin on " + date;
263         } else {
264             String name = descriptor.getArtifactId();
265             String version = descriptor.getVersion();
266             return "Listing generated by the " + name + " v" + version + " on " + date;
267         }
268     }
269 
270     /**
271      * Create an object in the bucket under a key that lets a normal http request function correctly with CloudFront /
272      * S3.<br>
273      * Either use the client's object or upload some html created by this plugin<br>
274      */
275     protected void updateDirectory(final S3PrefixContext context, final boolean isCopyIfExists, final String copyToKey)
276             throws IOException {
277         S3BucketContext bucketContext = context.getBucketContext();
278         AmazonS3Client client = context.getBucketContext().getClient();
279         String bucket = bucketContext.getBucket();
280 
281         boolean containsDefaultObject = isExistingObject(context.getObjectListing(), context.getDefaultObjectKey());
282         if (containsDefaultObject && isCopyIfExists) {
283             // Copy the contents of the clients default object
284             String sourceKey = context.getDefaultObjectKey();
285             String destKey = copyToKey;
286             CopyObjectRequest request = getCopyObjectRequest(bucket, sourceKey, destKey);
287             getLog().info("Copy: " + sourceKey + " to " + destKey);
288             client.copyObject(request);
289         } else {
290             // Upload our custom content
291             PutObjectRequest request = getPutIndexObjectRequest(context.getHtml(), copyToKey);
292             getLog().info("Put: " + copyToKey);
293             client.putObject(request);
294         }
295     }
296 
297     /**
298      * Update this S3 "directory".
299      */
300     protected void updateDirectory(final S3PrefixContext context) throws IOException {
301         String trimmedPrefix = converter.getTrimmedPrefix(context.getPrefix(), context.getBucketContext()
302                 .getDelimiter());
303 
304         // Handle "http://www.mybucket.com/foo/bar/"
305         updateDirectory(context, isCopyDefaultObjectWithDelimiter(), context.getPrefix());
306 
307         // Handle "http://www.mybucket.com/foo/bar"
308         updateDirectory(context, isCopyDefaultObjectWithoutDelimiter(), trimmedPrefix);
309 
310         // Handle "http://www.mybucket.com/foo/bar/browse.html"
311         // context.getBucketContext().getClient().putObject(getPutIndexObjectRequest(context.getHtml(),
312         // context.getBrowseHtmlKey()));
313     }
314 
315     /**
316      * If this is the root of the bucket and the default object either does not exist or was created by this plugin,
317      * overwrite the default object with newly generated html. Otherwise, do nothing.
318      */
319     protected void handleRoot(final S3PrefixContext context) throws IOException {
320         if (!context.isRoot()) {
321             return;
322         }
323 
324         AmazonS3Client client = context.getBucketContext().getClient();
325 
326         // Handle "http://www.mybucket.com/browse.html"
327         PutObjectRequest request1 = getPutIndexObjectRequest(context.getHtml(), context.getBrowseHtmlKey());
328         getLog().info("Put: " + context.getBrowseHtmlKey());
329         client.putObject(request1);
330 
331         boolean isCreateOrUpdateDefaultObject = isCreateOrUpdateDefaultObject(context);
332         if (!isCreateOrUpdateDefaultObject) {
333             return;
334         }
335 
336         // Update the default object
337         PutObjectRequest request2 = getPutIndexObjectRequest(context.getHtml(), context.getDefaultObjectKey());
338         getLog().info("Put: " + context.getDefaultObjectKey());
339         client.putObject(request2);
340     }
341 
342     protected S3PrefixContext getS3PrefixContext(final S3BucketContext context, final String prefix) {
343         ListObjectsRequest request = new ListObjectsRequest(context.getBucket(), prefix, null, context.getDelimiter(),
344                 1000);
345         ObjectListing objectListing = context.getClient().listObjects(request);
346         List<String[]> data = converter.getData(objectListing, prefix, context.getDelimiter());
347         String html = generator.getHtml(data, prefix, context.getDelimiter());
348         String defaultObjectKey = StringUtils.isEmpty(prefix) ? getDefaultObject() : prefix + getDefaultObject();
349         String browseHtmlKey = StringUtils.isEmpty(prefix) ? getBrowseHtml() : prefix + getBrowseHtml();
350         // Is this the root of the bucket?
351         boolean isRoot = StringUtils.isEmpty(prefix);
352 
353         S3PrefixContext prefixContext = new S3PrefixContext();
354         prefixContext.setObjectListing(objectListing);
355         prefixContext.setHtml(html);
356         prefixContext.setRoot(isRoot);
357         prefixContext.setDefaultObjectKey(defaultObjectKey);
358         prefixContext.setPrefix(prefix);
359         prefixContext.setBucketContext(context);
360         prefixContext.setBrowseHtmlKey(browseHtmlKey);
361         return prefixContext;
362     }
363 
364     /**
365      * Recurse the hierarchy of a bucket starting at "prefix" and create entries in the bucket corresponding to the
366      * directory structure of the hierarchy
367      */
368     protected void recurse(final S3BucketContext context, final String prefix) throws IOException {
369         S3PrefixContext prefixContext = getS3PrefixContext(context, prefix);
370 
371         handleRoot(prefixContext);
372 
373         // If this is not the root, there is more to do
374         if (!prefixContext.isRoot()) {
375             updateDirectory(prefixContext);
376         }
377 
378         // Recurse down the hierarchy
379         List<String> commonPrefixes = prefixContext.getObjectListing().getCommonPrefixes();
380         for (String commonPrefix : commonPrefixes) {
381             recurse(context, commonPrefix);
382         }
383     }
384 
385     /**
386      * Return true if the ObjectListing contains an object under "key"
387      */
388     protected boolean isExistingObject(final ObjectListing objectListing, final String key) {
389         List<S3ObjectSummary> summaries = objectListing.getObjectSummaries();
390         for (S3ObjectSummary summary : summaries) {
391             if (key.equals(summary.getKey())) {
392                 return true;
393             }
394         }
395         return false;
396     }
397 
398     /**
399      * Return true if there is no object in the ObjectListing under defaultObjectKey.<br>
400      * Return true if the object in the ObjectListing was created by this plugin.<br>
401      * Return false otherwise.<br>
402      */
403     protected boolean isCreateOrUpdateDefaultObject(final S3PrefixContext context) {
404         if (!isExistingObject(context.getObjectListing(), context.getDefaultObjectKey())) {
405             // There is no default object, we are free to create one
406             return true;
407         }
408         S3BucketContext s3Context = context.getBucketContext();
409         // There is a default object, but if it was created by this plugin, we
410         // still need to update it
411         S3Object s3Object = s3Context.getClient().getObject(s3Context.getBucket(), context.getDefaultObjectKey());
412         boolean isOurDefaultObject = isOurObject(s3Object);
413         IOUtils.closeQuietly(s3Object.getObjectContent());
414         if (isOurDefaultObject) {
415             return true;
416         } else {
417             return false;
418         }
419     }
420 
421     /**
422      * Return true if this S3Object was created by this plugin. This is is done by checking the metadata attached to
423      * this object for the presence of a custom value.
424      */
425     protected boolean isOurObject(final S3Object s3Object) {
426         ObjectMetadata metadata = s3Object.getObjectMetadata();
427         Map<String, String> userMetadata = metadata.getUserMetadata();
428         String value = userMetadata.get(S3_INDEX_METADATA_KEY);
429         boolean isOurObject = "true".equals(value);
430         return isOurObject;
431     }
432 
433     /**
434      * Create a CopyObjectRequest with an ACL set to PublicRead
435      */
436     protected CopyObjectRequest getCopyObjectRequest(final String bucket, final String sourceKey, final String destKey) {
437         CopyObjectRequest request = new CopyObjectRequest(bucket, sourceKey, bucket, destKey);
438         request.setCannedAccessControlList(CannedAccessControlList.PublicRead);
439         return request;
440     }
441 
442     public String getTimezone() {
443         return timezone;
444     }
445 
446     public void setTimezone(final String timezone) {
447         this.timezone = timezone;
448     }
449 
450     public String getDateFormat() {
451         return dateFormat;
452     }
453 
454     public void setDateFormat(final String dateFormat) {
455         this.dateFormat = dateFormat;
456     }
457 
458     public String getDefaultObject() {
459         return defaultObject;
460     }
461 
462     public void setDefaultObject(final String defaultCloudFrontObject) {
463         this.defaultObject = defaultCloudFrontObject;
464     }
465 
466     public String getFileImage() {
467         return fileImage;
468     }
469 
470     public void setFileImage(final String fileImage) {
471         this.fileImage = fileImage;
472     }
473 
474     public String getDirectoryImage() {
475         return directoryImage;
476     }
477 
478     public void setDirectoryImage(final String directoryImage) {
479         this.directoryImage = directoryImage;
480     }
481 
482     public String getCss() {
483         return css;
484     }
485 
486     public void setCss(final String css) {
487         this.css = css;
488     }
489 
490     public boolean isCopyDefaultObjectWithDelimiter() {
491         return copyDefaultObjectWithDelimiter;
492     }
493 
494     public void setCopyDefaultObjectWithDelimiter(final boolean copyDefaultObjectWithDelimiter) {
495         this.copyDefaultObjectWithDelimiter = copyDefaultObjectWithDelimiter;
496     }
497 
498     public boolean isCopyDefaultObjectWithoutDelimiter() {
499         return copyDefaultObjectWithoutDelimiter;
500     }
501 
502     public void setCopyDefaultObjectWithoutDelimiter(final boolean copyDefaultObjectWithoutDelimiter) {
503         this.copyDefaultObjectWithoutDelimiter = copyDefaultObjectWithoutDelimiter;
504     }
505 
506     public String getCacheControl() {
507         return cacheControl;
508     }
509 
510     public void setCacheControl(final String cacheControl) {
511         this.cacheControl = cacheControl;
512     }
513 
514     public String getBrowseHtml() {
515         return browseHtml;
516     }
517 
518     public void setBrowseHtml(final String browseHtml) {
519         this.browseHtml = browseHtml;
520     }
521 
522     /**
523      * @return the recurse
524      */
525     public boolean isRecurse() {
526         return recurse;
527     }
528 
529     /**
530      * @param recurse
531      * the recurse to set
532      */
533     public void setRecurse(final boolean recurse) {
534         this.recurse = recurse;
535     }
536 
537     /**
538      * @return the organizationGroupId
539      */
540     public String getOrganizationGroupId() {
541         return organizationGroupId;
542     }
543 
544     /**
545      * @param organizationGroupId
546      * the organizationGroupId to set
547      */
548     public void setOrganizationGroupId(final String organizationGroupId) {
549         this.organizationGroupId = organizationGroupId;
550     }
551 
552 }