Create a really simple Amazon S3 REST client
[egit/zawir.git] / org.spearce.jgit / src / org / spearce / jgit / transport / AmazonS3.java
blobc15248e18d003cea12adc4a2e02a94791ceffc3b
1 /*
2 * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
4 * All rights reserved.
6 * Redistribution and use in source and binary forms, with or
7 * without modification, are permitted provided that the following
8 * conditions are met:
10 * - Redistributions of source code must retain the above copyright
11 * notice, this list of conditions and the following disclaimer.
13 * - Redistributions in binary form must reproduce the above
14 * copyright notice, this list of conditions and the following
15 * disclaimer in the documentation and/or other materials provided
16 * with the distribution.
18 * - Neither the name of the Git Development Community nor the
19 * names of its contributors may be used to endorse or promote
20 * products derived from this software without specific prior
21 * written permission.
23 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
24 * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
25 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
26 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
27 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
28 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
29 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
30 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
31 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
32 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
33 * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
34 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
35 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
38 package org.spearce.jgit.transport;
40 import java.io.EOFException;
41 import java.io.File;
42 import java.io.FileInputStream;
43 import java.io.FileNotFoundException;
44 import java.io.IOException;
45 import java.io.InputStream;
46 import java.io.OutputStream;
47 import java.net.HttpURLConnection;
48 import java.net.Proxy;
49 import java.net.ProxySelector;
50 import java.net.URL;
51 import java.net.URLConnection;
52 import java.security.DigestOutputStream;
53 import java.security.InvalidKeyException;
54 import java.security.MessageDigest;
55 import java.security.NoSuchAlgorithmException;
56 import java.text.SimpleDateFormat;
57 import java.util.ArrayList;
58 import java.util.Collections;
59 import java.util.Date;
60 import java.util.HashSet;
61 import java.util.Iterator;
62 import java.util.List;
63 import java.util.Locale;
64 import java.util.Map;
65 import java.util.Properties;
66 import java.util.Set;
67 import java.util.SortedMap;
68 import java.util.TimeZone;
69 import java.util.TreeMap;
71 import javax.crypto.Mac;
72 import javax.crypto.spec.SecretKeySpec;
74 import org.spearce.jgit.awtui.AwtAuthenticator;
75 import org.spearce.jgit.lib.Constants;
76 import org.spearce.jgit.util.Base64;
77 import org.spearce.jgit.util.HttpSupport;
78 import org.spearce.jgit.util.TemporaryBuffer;
79 import org.xml.sax.Attributes;
80 import org.xml.sax.InputSource;
81 import org.xml.sax.SAXException;
82 import org.xml.sax.XMLReader;
83 import org.xml.sax.helpers.DefaultHandler;
84 import org.xml.sax.helpers.XMLReaderFactory;
86 /**
87 * A simple HTTP REST client for the Amazon S3 service.
88 * <p>
89 * This client uses the REST API to communicate with the Amazon S3 servers and
90 * read or write content through a bucket that the user has access to. It is a
91 * very lightweight implementation of the S3 API and therefore does not have all
92 * of the bells and whistles of popular client implementations.
93 * <p>
94 * Authentication is always performed using the user's AWSAccessKeyId and their
95 * private AWSSecretAccessKey.
97 public class AmazonS3 {
98 private static final Set<String> SIGNED_HEADERS;
100 private static final String HMAC = "HmacSHA1";
102 private static final String DOMAIN = "s3.amazonaws.com";
104 private static final String X_AMZ_ACL = "x-amz-acl";
106 static {
107 SIGNED_HEADERS = new HashSet<String>();
108 SIGNED_HEADERS.add("content-type");
109 SIGNED_HEADERS.add("content-md5");
110 SIGNED_HEADERS.add("date");
113 private static boolean isSignedHeader(final String name) {
114 final String nameLC = name.toLowerCase();
115 return SIGNED_HEADERS.contains(nameLC) || nameLC.startsWith("x-amz-");
118 private static String toCleanString(final List<String> list) {
119 final StringBuilder s = new StringBuilder();
120 for (final String v : list) {
121 if (s.length() > 0)
122 s.append(',');
123 s.append(v.replaceAll("\n", "").trim());
125 return s.toString();
128 private static String remove(final Map<String, String> m, final String k) {
129 final String r = m.remove(k);
130 return r != null ? r : "";
133 private static String httpNow() {
134 final String tz = "GMT";
135 final SimpleDateFormat fmt;
136 fmt = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US);
137 fmt.setTimeZone(TimeZone.getTimeZone(tz));
138 return fmt.format(new Date()) + " " + tz;
141 private static MessageDigest newMD5() {
142 try {
143 return MessageDigest.getInstance("MD5");
144 } catch (NoSuchAlgorithmException e) {
145 throw new RuntimeException("JRE lacks MD5 implementation", e);
149 /** AWSAccessKeyId, public string that identifies the user's account. */
150 private final String publicKey;
152 /** Decoded form of the private AWSSecretAccessKey, to sign requests. */
153 private final SecretKeySpec privateKey;
155 /** Our HTTP proxy support, in case we are behind a firewall. */
156 private final ProxySelector proxySelector;
158 /** ACL to apply to created objects. */
159 private final String acl;
161 /** Maximum number of times to try an operation. */
162 private final int maxAttempts;
165 * Create a new S3 client for the supplied user information.
166 * <p>
167 * The connection properties are a subset of those supported by the popular
168 * <a href="http://jets3t.s3.amazonaws.com/index.html">jets3t</a> library.
169 * For example:
171 * <pre>
172 * # AWS Access and Secret Keys (required)
173 * accesskey: &lt;YourAWSAccessKey&gt;
174 * secretkey: &lt;YourAWSSecretKey&gt;
176 * # Access Control List setting to apply to uploads, must be one of:
177 * # PRIVATE, PUBLIC_READ (defaults to PRIVATE).
178 * acl: PRIVATE
180 * # Number of times to retry after internal error from S3.
181 * httpclient.retry-max: 3
182 * </pre>
184 * @param props
185 * connection properties.
188 public AmazonS3(final Properties props) {
189 publicKey = props.getProperty("accesskey");
190 if (publicKey == null)
191 throw new IllegalArgumentException("Missing accesskey.");
193 final String secret = props.getProperty("secretkey");
194 if (secret == null)
195 throw new IllegalArgumentException("Missing secretkey.");
196 privateKey = new SecretKeySpec(Constants.encodeASCII(secret), HMAC);
198 final String pacl = props.getProperty("acl", "PRIVATE");
199 if ("PRIVATE".equalsIgnoreCase(pacl))
200 acl = "private";
201 else if ("PUBLIC".equalsIgnoreCase(pacl))
202 acl = "public-read";
203 else if ("PUBLIC-READ".equalsIgnoreCase(pacl))
204 acl = "public-read";
205 else if ("PUBLIC_READ".equalsIgnoreCase(pacl))
206 acl = "public-read";
207 else
208 throw new IllegalArgumentException("Invalid acl: " + pacl);
210 maxAttempts = Integer.parseInt(props.getProperty(
211 "httpclient.retry-max", "3"));
212 proxySelector = ProxySelector.getDefault();
216 * Get the content of a bucket object.
218 * @param bucket
219 * name of the bucket storing the object.
220 * @param key
221 * key of the object within its bucket.
222 * @return connection to stream the content of the object. The request
223 * properties of the connection may not be modified by the caller as
224 * the request parameters have already been signed.
225 * @throws IOException
226 * sending the request was not possible.
228 public URLConnection get(final String bucket, final String key)
229 throws IOException {
230 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
231 final HttpURLConnection c = open("GET", bucket, key);
232 authorize(c);
233 switch (HttpSupport.response(c)) {
234 case HttpURLConnection.HTTP_OK:
235 return c;
236 case HttpURLConnection.HTTP_NOT_FOUND:
237 throw new FileNotFoundException(key);
238 case HttpURLConnection.HTTP_INTERNAL_ERROR:
239 continue;
240 default:
241 throw error("Reading", key, c);
244 throw maxAttempts("Reading", key);
248 * List the names of keys available within a bucket.
249 * <p>
250 * This method is primarily meant for obtaining a "recursive directory
251 * listing" rooted under the specified bucket and prefix location.
253 * @param bucket
254 * name of the bucket whose objects should be listed.
255 * @param prefix
256 * common prefix to filter the results by. Must not be null.
257 * Supplying the empty string will list all keys in the bucket.
258 * Supplying a non-empty string will act as though a trailing '/'
259 * appears in prefix, even if it does not.
260 * @return list of keys starting with <code>prefix</code>, after removing
261 * <code>prefix</code> (or <code>prefix + "/"</code>)from all
262 * of them.
263 * @throws IOException
264 * sending the request was not possible, or the response XML
265 * document could not be parsed properly.
267 public List<String> list(final String bucket, String prefix)
268 throws IOException {
269 if (prefix.length() > 0 && !prefix.endsWith("/"))
270 prefix += "/";
271 final ListParser lp = new ListParser(bucket, prefix);
272 do {
273 lp.list();
274 } while (lp.truncated);
275 return lp.entries;
279 * Delete a single object.
280 * <p>
281 * Deletion always succeeds, even if the object does not exist.
283 * @param bucket
284 * name of the bucket storing the object.
285 * @param key
286 * key of the object within its bucket.
287 * @throws IOException
288 * deletion failed due to communications error.
290 public void delete(final String bucket, final String key)
291 throws IOException {
292 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
293 final HttpURLConnection c = open("DELETE", bucket, key);
294 authorize(c);
295 switch (HttpSupport.response(c)) {
296 case HttpURLConnection.HTTP_NO_CONTENT:
297 return;
298 case HttpURLConnection.HTTP_INTERNAL_ERROR:
299 continue;
300 default:
301 throw error("Deletion", key, c);
304 throw maxAttempts("Deletion", key);
308 * Atomically create or replace a single small object.
309 * <p>
310 * This form is only suitable for smaller contents, where the caller can
311 * reasonable fit the entire thing into memory.
312 * <p>
313 * End-to-end data integrity is assured by internally computing the MD5
314 * checksum of the supplied data and transmitting the checksum along with
315 * the data itself.
317 * @param bucket
318 * name of the bucket storing the object.
319 * @param key
320 * key of the object within its bucket.
321 * @param data
322 * new data content for the object. Must not be null. Zero length
323 * array will create a zero length object.
324 * @throws IOException
325 * creation/updating failed due to communications error.
327 public void put(final String bucket, final String key, final byte[] data)
328 throws IOException {
329 final String md5str = Base64.encodeBytes(newMD5().digest(data));
330 final String lenstr = String.valueOf(data.length);
331 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
332 final HttpURLConnection c = open("PUT", bucket, key);
333 c.setRequestProperty("Content-Length", lenstr);
334 c.setRequestProperty("Content-MD5", md5str);
335 c.setRequestProperty(X_AMZ_ACL, acl);
336 authorize(c);
337 c.setDoOutput(true);
338 c.setFixedLengthStreamingMode(data.length);
339 final OutputStream os = c.getOutputStream();
340 try {
341 os.write(data);
342 } finally {
343 os.close();
346 switch (HttpSupport.response(c)) {
347 case HttpURLConnection.HTTP_OK:
348 return;
349 case HttpURLConnection.HTTP_INTERNAL_ERROR:
350 continue;
351 default:
352 throw error("Writing", key, c);
355 throw maxAttempts("Writing", key);
359 * Atomically create or replace a single large object.
360 * <p>
361 * Initially the returned output stream buffers data into memory, but if the
362 * total number of written bytes starts to exceed an internal limit the data
363 * is spooled to a temporary file on the local drive.
364 * <p>
365 * Network transmission is attempted only when <code>close()</code> gets
366 * called at the end of output. Closing the returned stream can therefore
367 * take significant time, especially if the written content is very large.
368 * <p>
369 * End-to-end data integrity is assured by internally computing the MD5
370 * checksum of the supplied data and transmitting the checksum along with
371 * the data itself.
373 * @param bucket
374 * name of the bucket storing the object.
375 * @param key
376 * key of the object within its bucket.
377 * @return a stream which accepts the new data, and transmits once closed.
379 public OutputStream beginPut(final String bucket, final String key) {
380 final MessageDigest md5 = newMD5();
381 final TemporaryBuffer buffer = new TemporaryBuffer() {
382 @Override
383 public void close() throws IOException {
384 super.close();
385 try {
386 putImpl(bucket, key, md5.digest(), this);
387 } finally {
388 destroy();
392 return new DigestOutputStream(buffer, md5);
395 private void putImpl(final String bucket, final String key,
396 final byte[] csum, final TemporaryBuffer buf) throws IOException {
397 final String md5str = Base64.encodeBytes(csum);
398 final long len = buf.length();
399 final String lenstr = String.valueOf(len);
400 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
401 final HttpURLConnection c = open("PUT", bucket, key);
402 c.setRequestProperty("Content-Length", lenstr);
403 c.setRequestProperty("Content-MD5", md5str);
404 c.setRequestProperty(X_AMZ_ACL, acl);
405 authorize(c);
406 c.setDoOutput(true);
407 c.setFixedLengthStreamingMode((int) len);
408 final OutputStream os = c.getOutputStream();
409 try {
410 buf.writeTo(os, null);
411 } finally {
412 os.close();
415 switch (HttpSupport.response(c)) {
416 case HttpURLConnection.HTTP_OK:
417 return;
418 case HttpURLConnection.HTTP_INTERNAL_ERROR:
419 continue;
420 default:
421 throw error("Writing", key, c);
424 throw maxAttempts("Writing", key);
427 private IOException error(final String action, final String key,
428 final HttpURLConnection c) throws IOException {
429 return new IOException(action + " of '" + key + "' failed: "
430 + HttpSupport.response(c) + " " + c.getResponseMessage());
433 private IOException maxAttempts(final String action, final String key) {
434 return new IOException(action + " of '" + key + "' failed:"
435 + " Giving up after " + maxAttempts + " attempts.");
438 private HttpURLConnection open(final String method, final String bucket,
439 final String key) throws IOException {
440 final Map<String, String> noArgs = Collections.emptyMap();
441 return open(method, bucket, key, noArgs);
444 private HttpURLConnection open(final String method, final String bucket,
445 final String key, final Map<String, String> args)
446 throws IOException {
447 final StringBuilder urlstr = new StringBuilder();
448 urlstr.append("http://");
449 urlstr.append(bucket);
450 urlstr.append('.');
451 urlstr.append(DOMAIN);
452 urlstr.append('/');
453 if (key.length() > 0)
454 HttpSupport.encode(urlstr, key);
455 if (!args.isEmpty()) {
456 final Iterator<Map.Entry<String, String>> i;
458 urlstr.append('?');
459 i = args.entrySet().iterator();
460 while (i.hasNext()) {
461 final Map.Entry<String, String> e = i.next();
462 urlstr.append(e.getKey());
463 urlstr.append('=');
464 HttpSupport.encode(urlstr, e.getValue());
465 if (i.hasNext())
466 urlstr.append('&');
470 final URL url = new URL(urlstr.toString());
471 final Proxy proxy = HttpSupport.proxyFor(proxySelector, url);
472 final HttpURLConnection c;
474 c = (HttpURLConnection) url.openConnection(proxy);
475 c.setRequestMethod(method);
476 c.setRequestProperty("User-Agent", "jgit/1.0");
477 c.setRequestProperty("Date", httpNow());
478 return c;
481 private void authorize(final HttpURLConnection c) throws IOException {
482 final Map<String, List<String>> reqHdr = c.getRequestProperties();
483 final SortedMap<String, String> sigHdr = new TreeMap<String, String>();
484 for (final String hdr : reqHdr.keySet()) {
485 if (isSignedHeader(hdr))
486 sigHdr.put(hdr.toLowerCase(), toCleanString(reqHdr.get(hdr)));
489 final StringBuilder s = new StringBuilder();
490 s.append(c.getRequestMethod());
491 s.append('\n');
493 s.append(remove(sigHdr, "content-md5"));
494 s.append('\n');
496 s.append(remove(sigHdr, "content-type"));
497 s.append('\n');
499 s.append(remove(sigHdr, "date"));
500 s.append('\n');
502 for (final Map.Entry<String, String> e : sigHdr.entrySet()) {
503 s.append(e.getKey());
504 s.append(':');
505 s.append(e.getValue());
506 s.append('\n');
509 final String host = c.getURL().getHost();
510 s.append('/');
511 s.append(host.substring(0, host.length() - DOMAIN.length() - 1));
512 s.append(c.getURL().getPath());
514 final String sec;
515 try {
516 final Mac m = Mac.getInstance(HMAC);
517 m.init(privateKey);
518 sec = Base64.encodeBytes(m.doFinal(s.toString().getBytes("UTF-8")));
519 } catch (NoSuchAlgorithmException e) {
520 throw new IOException("No " + HMAC + " support:" + e.getMessage());
521 } catch (InvalidKeyException e) {
522 throw new IOException("Invalid key: " + e.getMessage());
524 c.setRequestProperty("Authorization", "AWS " + publicKey + ":" + sec);
528 * Simple command line interface to {@link AmazonS3}.
530 * @param argv
531 * command line arguments. See usage for details.
532 * @throws IOException
533 * an error occurred.
535 public static void main(final String[] argv) throws IOException {
536 if (argv.length != 4) {
537 commandLineUsage();
538 return;
541 AwtAuthenticator.install();
542 HttpSupport.configureHttpProxy();
544 final AmazonS3 s3 = new AmazonS3(properties(new File(argv[0])));
545 final String op = argv[1];
546 final String bucket = argv[2];
547 final String key = argv[3];
548 if ("get".equals(op)) {
549 final URLConnection c = s3.get(bucket, key);
550 int len = c.getContentLength();
551 final InputStream in = c.getInputStream();
552 try {
553 final byte[] tmp = new byte[2048];
554 while (len > 0) {
555 final int n = in.read(tmp);
556 if (n < 0)
557 throw new EOFException("Expected " + len + " bytes.");
558 System.out.write(tmp, 0, n);
559 len -= n;
561 } finally {
562 in.close();
564 } else if ("ls".equals(op) || "list".equals(op)) {
565 for (final String k : s3.list(bucket, key))
566 System.out.println(k);
567 } else if ("rm".equals(op) || "delete".equals(op)) {
568 s3.delete(bucket, key);
569 } else if ("put".equals(op)) {
570 final OutputStream os = s3.beginPut(bucket, key);
571 final byte[] tmp = new byte[2048];
572 int n;
573 while ((n = System.in.read(tmp)) > 0)
574 os.write(tmp, 0, n);
575 os.close();
576 } else {
577 commandLineUsage();
581 private static void commandLineUsage() {
582 System.err.println("usage: conn.prop op bucket key");
583 System.err.println();
584 System.err.println(" where conn.prop is a jets3t properties file.");
585 System.err.println(" op is one of: get ls rm put");
586 System.err.println(" bucket is the name of the S3 bucket");
587 System.err.println(" key is the name of the object.");
588 System.exit(1);
591 static Properties properties(final File authFile)
592 throws FileNotFoundException, IOException {
593 final Properties p = new Properties();
594 final FileInputStream in = new FileInputStream(authFile);
595 try {
596 p.load(in);
597 } finally {
598 in.close();
600 return p;
603 private final class ListParser extends DefaultHandler {
604 final List<String> entries = new ArrayList<String>();
606 private final String bucket;
608 private final String prefix;
610 boolean truncated;
612 private StringBuilder data;
614 ListParser(final String bn, final String p) {
615 bucket = bn;
616 prefix = p;
619 void list() throws IOException {
620 final Map<String, String> args = new TreeMap<String, String>();
621 if (prefix.length() > 0)
622 args.put("prefix", prefix);
623 if (!entries.isEmpty())
624 args.put("marker", prefix + entries.get(entries.size() - 1));
626 for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
627 final HttpURLConnection c = open("GET", bucket, "", args);
628 authorize(c);
629 switch (HttpSupport.response(c)) {
630 case HttpURLConnection.HTTP_OK:
631 truncated = false;
632 data = null;
634 final XMLReader xr;
635 try {
636 xr = XMLReaderFactory.createXMLReader();
637 } catch (SAXException e) {
638 throw new IOException("No XML parser available.");
640 xr.setContentHandler(this);
641 final InputStream in = c.getInputStream();
642 try {
643 xr.parse(new InputSource(in));
644 } catch (SAXException parsingError) {
645 final IOException p;
646 p = new IOException("Error listing " + prefix);
647 p.initCause(parsingError);
648 throw p;
649 } finally {
650 in.close();
652 return;
654 case HttpURLConnection.HTTP_INTERNAL_ERROR:
655 continue;
657 default:
658 throw AmazonS3.this.error("Listing", prefix, c);
661 throw maxAttempts("Listing", prefix);
664 @Override
665 public void startElement(final String uri, final String name,
666 final String qName, final Attributes attributes)
667 throws SAXException {
668 if ("Key".equals(name) || "IsTruncated".equals(name))
669 data = new StringBuilder();
672 @Override
673 public void ignorableWhitespace(final char[] ch, final int s,
674 final int n) throws SAXException {
675 if (data != null)
676 data.append(ch, s, n);
679 @Override
680 public void characters(final char[] ch, final int s, final int n)
681 throws SAXException {
682 if (data != null)
683 data.append(ch, s, n);
686 @Override
687 public void endElement(final String uri, final String name,
688 final String qName) throws SAXException {
689 if ("Key".equals(name))
690 entries.add(data.toString().substring(prefix.length()));
691 else if ("IsTruncated".equals(name))
692 truncated = "true".equalsIgnoreCase(data.toString());
693 data = null;