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;
195         }
196         if (s == null) {
197             return;
198         }
199         if (!s.endsWith(getDelimiter())) {
200             setPrefix(s + getDelimiter());
201         }
202     }
203 
204     protected S3BucketContext getS3BucketContext() throws MojoExecutionException {
205         updateCredentials();
206         validateCredentials();
207         AWSCredentials credentials = getCredentials();
208         AmazonS3Client client = new AmazonS3Client(credentials);
209         updatePrefix();
210         S3BucketContext context = new S3BucketContext();
211         try {
212             BeanUtils.copyProperties(context, this);
213         } catch (Exception e) {
214             throw new MojoExecutionException("Error copying properties", e);
215         }
216         context.setClient(client);
217         context.setLastModifiedDateFormatter(getLastModifiedDateFormatter());
218         context.setAbout(getAbout());
219         return context;
220     }
221 
222     /**
223      * Create a PutObjectRequest for some html generated by this mojo. The PutObjectRequest sets the content type to
224      * S3_INDEX_CONTENT_TYPE, sets the ACL to PublicRead, and adds some custom metadata so we can positively identify it
225      * as an object created by this plugin
226      */
227     protected PutObjectRequest getPutIndexObjectRequest(final String html, final String key) throws IOException {
228         InputStream in = new ByteArrayInputStream(html.getBytes());
229         ObjectMetadata om = new ObjectMetadata();
230         om.setCacheControl(getCacheControl());
231         String contentType = S3_INDEX_CONTENT_TYPE;
232         om.setContentType(contentType);
233         om.setContentLength(html.length());
234         om.addUserMetadata(S3_INDEX_METADATA_KEY, "true");
235         PutObjectRequest request = new PutObjectRequest(getBucket(), key, in, om);
236         request.setCannedAcl(CannedAccessControlList.PublicRead);
237         return request;
238     }
239 
240     /**
241      * Return a SimpleDateFormat object initialized with the date format and timezone supplied to the mojo
242      */
243     protected SimpleDateFormat getLastModifiedDateFormatter() {
244         SimpleDateFormat sdf = new SimpleDateFormat(getDateFormat());
245         sdf.setTimeZone(TimeZone.getTimeZone(getTimezone()));
246         return sdf;
247     }
248 
249     /**
250      * Return true if the Collection is null or contains no entries, false otherwise
251      */
252     protected boolean isEmpty(final Collection<?> c) {
253         return c == null || c.size() == 0;
254     }
255 
256     /**
257      * Show some text about this plugin
258      */
259     protected String getAbout() {
260         String date = getLastModifiedDateFormatter().format(new Date());
261         PluginDescriptor descriptor = (PluginDescriptor) this.getPluginContext().get("pluginDescriptor");
262         if (descriptor == null) {
263             // Maven 2.2.1 is returning a null descriptor
264             return "Listing generated by the maven-cloudfront-plugin on " + date;
265         } else {
266             String name = descriptor.getArtifactId();
267             String version = descriptor.getVersion();
268             return "Listing generated by the " + name + " v" + version + " on " + date;
269         }
270     }
271 
272     /**
273      * Create an object in the bucket under a key that lets a normal http request function correctly with CloudFront /
274      * S3.<br>
275      * Either use the client's object or upload some html created by this plugin<br>
276      */
277     protected void updateDirectory(final S3PrefixContext context, final boolean isCopyIfExists, final String copyToKey)
278             throws IOException {
279         S3BucketContext bucketContext = context.getBucketContext();
280         AmazonS3Client client = context.getBucketContext().getClient();
281         String bucket = bucketContext.getBucket();
282 
283         boolean containsDefaultObject = isExistingObject(context.getObjectListing(), context.getDefaultObjectKey());
284         if (containsDefaultObject && isCopyIfExists) {
285             // Copy the contents of the clients default object
286             String sourceKey = context.getDefaultObjectKey();
287             String destKey = copyToKey;
288             CopyObjectRequest request = getCopyObjectRequest(bucket, sourceKey, destKey);
289             getLog().info("Copy: " + sourceKey + " to " + destKey);
290             client.copyObject(request);
291         } else {
292             // Upload our custom content
293             PutObjectRequest request = getPutIndexObjectRequest(context.getHtml(), copyToKey);
294             getLog().info("Put: " + copyToKey);
295             client.putObject(request);
296         }
297     }
298 
299     /**
300      * Update this S3 "directory".
301      */
302     protected void updateDirectory(final S3PrefixContext context) throws IOException {
303         String trimmedPrefix = converter.getTrimmedPrefix(context.getPrefix(), context.getBucketContext()
304                 .getDelimiter());
305 
306         // Handle "http://www.mybucket.com/foo/bar/"
307         updateDirectory(context, isCopyDefaultObjectWithDelimiter(), context.getPrefix());
308 
309         // Handle "http://www.mybucket.com/foo/bar"
310         updateDirectory(context, isCopyDefaultObjectWithoutDelimiter(), trimmedPrefix);
311 
312         // Handle "http://www.mybucket.com/foo/bar/browse.html"
313         // context.getBucketContext().getClient().putObject(getPutIndexObjectRequest(context.getHtml(),
314         // context.getBrowseHtmlKey()));
315     }
316 
317     /**
318      * If this is the root of the bucket and the default object either does not exist or was created by this plugin,
319      * overwrite the default object with newly generated html. Otherwise, do nothing.
320      */
321     protected void handleRoot(final S3PrefixContext context) throws IOException {
322         if (!context.isRoot()) {
323             return;
324         }
325 
326         AmazonS3Client client = context.getBucketContext().getClient();
327 
328         // Handle "http://www.mybucket.com/browse.html"
329         PutObjectRequest request1 = getPutIndexObjectRequest(context.getHtml(), context.getBrowseHtmlKey());
330         getLog().info("Put: " + context.getBrowseHtmlKey());
331         client.putObject(request1);
332 
333         boolean isCreateOrUpdateDefaultObject = isCreateOrUpdateDefaultObject(context);
334         if (!isCreateOrUpdateDefaultObject) {
335             return;
336         }
337 
338         // Update the default object
339         PutObjectRequest request2 = getPutIndexObjectRequest(context.getHtml(), context.getDefaultObjectKey());
340         getLog().info("Put: " + context.getDefaultObjectKey());
341         client.putObject(request2);
342     }
343 
344     protected S3PrefixContext getS3PrefixContext(final S3BucketContext context, final String prefix) {
345         ListObjectsRequest request = new ListObjectsRequest(context.getBucket(), prefix, null, context.getDelimiter(),
346                 1000);
347         ObjectListing objectListing = context.getClient().listObjects(request);
348         List<String[]> data = converter.getData(objectListing, prefix, context.getDelimiter());
349         String html = generator.getHtml(data, prefix, context.getDelimiter());
350         String defaultObjectKey = StringUtils.isEmpty(prefix) ? getDefaultObject() : prefix + getDefaultObject();
351         String browseHtmlKey = StringUtils.isEmpty(prefix) ? getBrowseHtml() : prefix + getBrowseHtml();
352         // Is this the root of the bucket?
353         boolean isRoot = StringUtils.isEmpty(prefix);
354 
355         S3PrefixContext prefixContext = new S3PrefixContext();
356         prefixContext.setObjectListing(objectListing);
357         prefixContext.setHtml(html);
358         prefixContext.setRoot(isRoot);
359         prefixContext.setDefaultObjectKey(defaultObjectKey);
360         prefixContext.setPrefix(prefix);
361         prefixContext.setBucketContext(context);
362         prefixContext.setBrowseHtmlKey(browseHtmlKey);
363         return prefixContext;
364     }
365 
366     /**
367      * Recurse the hierarchy of a bucket starting at "prefix" and create entries in the bucket corresponding to the
368      * directory structure of the hierarchy
369      */
370     protected void recurse(final S3BucketContext context, final String prefix) throws IOException {
371         S3PrefixContext prefixContext = getS3PrefixContext(context, prefix);
372 
373         handleRoot(prefixContext);
374 
375         // If this is not the root, there is more to do
376         if (!prefixContext.isRoot()) {
377             updateDirectory(prefixContext);
378         }
379 
380         // Recurse down the hierarchy
381         List<String> commonPrefixes = prefixContext.getObjectListing().getCommonPrefixes();
382         for (String commonPrefix : commonPrefixes) {
383             recurse(context, commonPrefix);
384         }
385     }
386 
387     /**
388      * Return true if the ObjectListing contains an object under "key"
389      */
390     protected boolean isExistingObject(final ObjectListing objectListing, final String key) {
391         List<S3ObjectSummary> summaries = objectListing.getObjectSummaries();
392         for (S3ObjectSummary summary : summaries) {
393             if (key.equals(summary.getKey())) {
394                 return true;
395             }
396         }
397         return false;
398     }
399 
400     /**
401      * Return true if there is no object in the ObjectListing under defaultObjectKey.<br>
402      * Return true if the object in the ObjectListing was created by this plugin.<br>
403      * Return false otherwise.<br>
404      */
405     protected boolean isCreateOrUpdateDefaultObject(final S3PrefixContext context) {
406         if (!isExistingObject(context.getObjectListing(), context.getDefaultObjectKey())) {
407             // There is no default object, we are free to create one
408             return true;
409         }
410         S3BucketContext s3Context = context.getBucketContext();
411         // There is a default object, but if it was created by this plugin, we
412         // still need to update it
413         S3Object s3Object = s3Context.getClient().getObject(s3Context.getBucket(), context.getDefaultObjectKey());
414         boolean isOurDefaultObject = isOurObject(s3Object);
415         IOUtils.closeQuietly(s3Object.getObjectContent());
416         if (isOurDefaultObject) {
417             return true;
418         } else {
419             return false;
420         }
421     }
422 
423     /**
424      * Return true if this S3Object was created by this plugin. This is is done by checking the metadata attached to
425      * this object for the presence of a custom value.
426      */
427     protected boolean isOurObject(final S3Object s3Object) {
428         ObjectMetadata metadata = s3Object.getObjectMetadata();
429         Map<String, String> userMetadata = metadata.getUserMetadata();
430         String value = userMetadata.get(S3_INDEX_METADATA_KEY);
431         boolean isOurObject = "true".equals(value);
432         return isOurObject;
433     }
434 
435     /**
436      * Create a CopyObjectRequest with an ACL set to PublicRead
437      */
438     protected CopyObjectRequest getCopyObjectRequest(final String bucket, final String sourceKey, final String destKey) {
439         CopyObjectRequest request = new CopyObjectRequest(bucket, sourceKey, bucket, destKey);
440         request.setCannedAccessControlList(CannedAccessControlList.PublicRead);
441         return request;
442     }
443 
444     public String getTimezone() {
445         return timezone;
446     }
447 
448     public void setTimezone(final String timezone) {
449         this.timezone = timezone;
450     }
451 
452     public String getDateFormat() {
453         return dateFormat;
454     }
455 
456     public void setDateFormat(final String dateFormat) {
457         this.dateFormat = dateFormat;
458     }
459 
460     public String getDefaultObject() {
461         return defaultObject;
462     }
463 
464     public void setDefaultObject(final String defaultCloudFrontObject) {
465         this.defaultObject = defaultCloudFrontObject;
466     }
467 
468     public String getFileImage() {
469         return fileImage;
470     }
471 
472     public void setFileImage(final String fileImage) {
473         this.fileImage = fileImage;
474     }
475 
476     public String getDirectoryImage() {
477         return directoryImage;
478     }
479 
480     public void setDirectoryImage(final String directoryImage) {
481         this.directoryImage = directoryImage;
482     }
483 
484     public String getCss() {
485         return css;
486     }
487 
488     public void setCss(final String css) {
489         this.css = css;
490     }
491 
492     public boolean isCopyDefaultObjectWithDelimiter() {
493         return copyDefaultObjectWithDelimiter;
494     }
495 
496     public void setCopyDefaultObjectWithDelimiter(final boolean copyDefaultObjectWithDelimiter) {
497         this.copyDefaultObjectWithDelimiter = copyDefaultObjectWithDelimiter;
498     }
499 
500     public boolean isCopyDefaultObjectWithoutDelimiter() {
501         return copyDefaultObjectWithoutDelimiter;
502     }
503 
504     public void setCopyDefaultObjectWithoutDelimiter(final boolean copyDefaultObjectWithoutDelimiter) {
505         this.copyDefaultObjectWithoutDelimiter = copyDefaultObjectWithoutDelimiter;
506     }
507 
508     public String getCacheControl() {
509         return cacheControl;
510     }
511 
512     public void setCacheControl(final String cacheControl) {
513         this.cacheControl = cacheControl;
514     }
515 
516     public String getBrowseHtml() {
517         return browseHtml;
518     }
519 
520     public void setBrowseHtml(final String browseHtml) {
521         this.browseHtml = browseHtml;
522     }
523 
524     /**
525      * @return the recurse
526      */
527     public boolean isRecurse() {
528         return recurse;
529     }
530 
531     /**
532      * @param recurse
533      * the recurse to set
534      */
535     public void setRecurse(final boolean recurse) {
536         this.recurse = recurse;
537     }
538 
539     /**
540      * @return the organizationGroupId
541      */
542     public String getOrganizationGroupId() {
543         return organizationGroupId;
544     }
545 
546     /**
547      * @param organizationGroupId
548      * the organizationGroupId to set
549      */
550     public void setOrganizationGroupId(final String organizationGroupId) {
551         this.organizationGroupId = organizationGroupId;
552     }
553 
554 }