s33r 0.1 → 0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,39 @@
1
+ # Parts of this code are heavily based on Amazon's code. Here's their license:
2
+ #
3
+ # This software code is made available "AS IS" without warranties of any
4
+ # kind. You may copy, display, modify and redistribute the software
5
+ # code either by itself or as incorporated into your code; provided that
6
+ # you do not remove any proprietary notices. Your use of this software
7
+ # code is at your own risk and you waive any claim against Amazon
8
+ # Digital Services, Inc. or its affiliates with respect to your use of
9
+ # this software code. (c) 2006 Amazon Digital Services, Inc. or its
10
+ # affiliates.
11
+
1
12
  require 'base64'
2
13
  require 'time'
14
+ require 'net/http'
3
15
  require 'net/https'
4
16
  require 'openssl'
17
+ require 'xml/libxml'
5
18
 
19
+ # this module handles operations which don't require an internet connection,
20
+ # i.e. data validation and request building operations;
21
+ # it also holds all the constants relating to S3
6
22
  module S3
7
23
  HOST = 's3.amazonaws.com'
8
24
  PORT = 443
25
+ NON_SSL_PORT = 80
9
26
  METADATA_PREFIX = 'x-amz-meta-'
10
27
  AWS_HEADER_PREFIX = 'x-amz-'
11
28
  AWS_AUTH_HEADER_VALUE = "AWS %s:%s"
12
29
  INTERESTING_HEADERS = ['content-md5', 'content-type', 'date']
13
30
  REQUIRED_HEADERS = ['Content-Type', 'Date']
14
31
  CANNED_ACLS = ['private', 'public-read', 'public-read-write', 'authenticated-read']
15
- METHOD_VERBS = ['GET', 'PUT', 'HEAD', 'POST']
32
+ METHOD_VERBS = ['GET', 'PUT', 'HEAD', 'POST', 'DELETE']
33
+ # maximum number which can be passed in max-keys parameter when GETting bucket list
34
+ BUCKET_LIST_MAX_MAX_KEYS = 1000
35
+ # default number of seconds an authenticated URL will last for (15 minutes)
36
+ DEFAULT_EXPIRY_SECS = 60 * 15
16
37
 
17
38
  # builds the canonical string for signing;
18
39
  # modified (slightly) from the Amazon sample code
@@ -75,7 +96,7 @@ module S3
75
96
 
76
97
  # encode the given string with the aws_secret_access_key, by taking the
77
98
  # hmac sha1 sum, and then base64 encoding it
78
- def generate_signature(aws_secret_access_key, str, urlencode=false)
99
+ def generate_signature(aws_secret_access_key, str)
79
100
  digest = OpenSSL::HMAC::digest(OpenSSL::Digest::Digest.new("SHA1"), aws_secret_access_key, str)
80
101
  Base64.encode64(digest).strip
81
102
  end
@@ -128,4 +149,61 @@ module S3
128
149
  mime_type = MIME::Types['text/plain'][0] unless mime_type
129
150
  mime_type
130
151
  end
152
+
153
+ # ensure that a bucket_name is well-formed (no leading or trailing slash)
154
+ def bucket_name_valid?(bucket_name)
155
+ if '/' == bucket_name[0,1]
156
+ raise S3Exception::MalformedBucketName, "Bucket name cannot have a leading slash"
157
+ elsif '/' == bucket_name[-1,1]
158
+ raise S3Exception::MalformedBucketName, "Bucket name cannot have a trailing slash"
159
+ end
160
+ end
161
+
162
+ # convert a hash of name/value pairs to querystring variables
163
+ # names can be strings or symbols
164
+ def generate_querystring(pairs={})
165
+ str = ''
166
+ if pairs.size > 0
167
+ str += "?" + pairs.map { |key, value| "#{key}=#{CGI::escape(value.to_s)}" }.join('&')
168
+ end
169
+ str
170
+ end
171
+
172
+ # put / between args, but only if it's not already there
173
+ def url_join(*args)
174
+ url_start = ''
175
+ url_end = args.join('/')
176
+
177
+ # string index where the scheme of the URL (xxxx://) ends
178
+ scheme_ends_at = (url_end =~ /:\/\//)
179
+ unless scheme_ends_at.nil?
180
+ scheme_ends_at = scheme_ends_at + 1
181
+ url_start = url_end[0..scheme_ends_at]
182
+ url_end = url_end[(scheme_ends_at + 1)..-1]
183
+ end
184
+
185
+ # replace any multiple forward slashes (except those in the scheme)
186
+ url_end = url_end.gsub(/\/{2,}/, '/')
187
+
188
+ return url_start + url_end
189
+ end
190
+
191
+ def s3_absolute_url(bucket_name, resource_key)
192
+ "http://" + HOST + '/' + bucket_name + '/' + resource_key
193
+ end
194
+
195
+ # generate a gettable URL for an S3 resource key which passes authentication in querystring
196
+ # int expires: when the URL expires (seconds since the epoch)
197
+ def s3_authenticated_url(aws_access_key, aws_secret_access_key, bucket_name, resource_key,
198
+ expires)
199
+ path = '/' + bucket_name + '/' + resource_key
200
+
201
+ canonical_string = generate_canonical_string('GET', path, {}, expires)
202
+ signature = generate_signature(aws_secret_access_key, canonical_string)
203
+
204
+ querystring = generate_querystring({ 'Signature' => signature, 'Expires' => expires,
205
+ 'AWSAccessKeyId' => aws_access_key })
206
+
207
+ return s3_absolute_url(bucket_name, resource_key) + querystring
208
+ end
131
209
  end
@@ -0,0 +1,29 @@
1
+ # convenience methods for libxml classes
2
+ module XML
3
+ # find first matching element and return its content
4
+ # xpath: XPath query
5
+ # returns nil if no element matches xpath
6
+ def xget(xpath)
7
+ nodes = self.find(xpath).to_a
8
+ if nodes.empty?
9
+ return nil
10
+ else
11
+ return nodes.first.content
12
+ end
13
+ end
14
+
15
+ # parse an XML string into an XML::Document instance
16
+ def XML.get_xml_doc(xml_str)
17
+ parser = XML::Parser.new
18
+ parser.string = xml_str
19
+ parser.parse
20
+ end
21
+
22
+ class Document
23
+ include XML
24
+ end
25
+
26
+ class Node
27
+ include XML
28
+ end
29
+ end
@@ -1,19 +1,40 @@
1
+ # TODO: provide access to metadata on the enclosed bucket_listing
2
+
1
3
  module S3
2
4
  # a client for dealing with a single bucket
3
5
  class NamedBucket < Client
4
6
  attr_accessor :bucket_name
5
-
6
- # TODO: check bucket exists before setting it
7
+
7
8
  # options available:
8
- # :public_contents => true: all items put into bucket are made public
9
+ # :public_contents => true: all items put into bucket are made public (can be overridden per request)
10
+ # :strict => true: check whether the bucket exists before attempting to initialize
9
11
  def initialize(aws_access_key, aws_secret_access_key, bucket_name, options={}, &block)
10
- super(aws_access_key, aws_secret_access_key)
11
12
  @bucket_name = bucket_name
13
+
14
+ # holds a BucketListing instance
15
+ @bucket_listing = nil
16
+
17
+ # all content should be created as public-read
12
18
  @client_headers.merge!(canned_acl_header('public-read')) if options[:public_contents]
13
- yield self if block_given?
19
+
20
+ super(aws_access_key, aws_secret_access_key, options)
21
+
22
+ if true == options[:strict]
23
+ raise S3Exception::MissingResource unless bucket_exists?(bucket_name)
24
+ end
14
25
  end
15
26
 
16
- def root
27
+ def metadata
28
+ # TODO: get bucket metadata from the bucket_listing
29
+ end
30
+
31
+ def contents
32
+ # TODO: S3Object instances inside the bucket_listing
33
+ end
34
+
35
+ def listing
36
+ # TODO: build a bucket_listing whose objects are associated with the bucket
37
+ # and set the @bucket_listing instance variable
17
38
  list_bucket(@bucket_name)
18
39
  end
19
40
 
@@ -21,8 +42,13 @@ module S3
21
42
  super(string, @bucket_name, resource_key, headers)
22
43
  end
23
44
 
24
- def put_file(filename, resource_key=nil, headers={})
25
- super(filename, @bucket_name, resource_key, headers)
45
+ def put_file(filename, resource_key=nil, headers={}, options={})
46
+ super(filename, @bucket_name, resource_key, headers, options)
47
+ end
48
+
49
+ # expires: time in secs since the epoch when
50
+ def s3_authenticated_url(resource_key, expires=(Time.now.to_i + DEFAULT_EXPIRY_SECS))
51
+ super(@aws_access_key, @aws_secret_access_key, @bucket_name, resource_key, expires)
26
52
  end
27
53
  end
28
54
  end
@@ -1,5 +1,8 @@
1
1
  require 'net/http'
2
2
 
3
+ # overrides for the default Net::HTTP classes
4
+
5
+ # add some convenience functions for checking response status
3
6
  class Net::HTTPResponse
4
7
  attr_accessor :success
5
8
 
@@ -11,11 +14,17 @@ class Net::HTTPResponse
11
14
  response.is_a?(Net::HTTPOK)
12
15
  end
13
16
 
17
+ def not_found
18
+ response.is_a?(Net::HTTPNotFound)
19
+ end
20
+
14
21
  def to_s
15
22
  body
16
23
  end
17
24
  end
18
25
 
26
+ # modified to enable larger chunk sizes to be used
27
+ # when streaming data from large files
19
28
  class Net::HTTPGenericRequest
20
29
  attr_accessor :chunk_size
21
30
 
@@ -39,4 +48,18 @@ class Net::HTTPGenericRequest
39
48
  end
40
49
  end
41
50
  end
51
+ end
52
+
53
+ class Net::HTTPRequest
54
+ def to_s
55
+ str = "*******\n" +
56
+ "#{self.class::METHOD} #{@path} HTTP/1.1\n" +
57
+ "Host: #{S3::HOST}\n"
58
+
59
+ self.each_capitalized do |key, value|
60
+ str += "#{key}: #{value}\n"
61
+ end
62
+ str += "*******\n\n"
63
+ str
64
+ end
42
65
  end
@@ -1,20 +1,29 @@
1
1
  module S3
2
2
  module S3Exception
3
-
3
+
4
4
  class MethodNotAvailable < Exception
5
5
  end
6
-
6
+
7
7
  class MissingRequiredHeaders < Exception
8
8
  end
9
-
9
+
10
10
  class UnsupportedCannedACL < Exception
11
11
  end
12
-
12
+
13
13
  class UnsupportedHTTPMethod < Exception
14
14
  end
15
-
15
+
16
16
  class MalformedBucketName < Exception
17
17
  end
18
18
 
19
+ class MissingResource < Exception
20
+ end
21
+
22
+ class BucketListingMaxKeysError < Exception
23
+ end
24
+
25
+ class InvalidBucketListing < Exception
26
+ end
27
+
19
28
  end
20
29
  end
@@ -0,0 +1,11 @@
1
+ module S3
2
+ module Sync
3
+
4
+ # get the MD5 checksum for a file (comparable to the ETag on S3 objects)
5
+ def md5sum(filename)
6
+ f = File.open(filename).binmode
7
+ OpenSSL::Digest::MD5.hexdigest(f.read)
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,125 @@
1
+ base = File.dirname(__FILE__)
2
+ require base + '/../s3_test_constants'
3
+ require 'set'
4
+
5
+ context 'S3 bucket listing' do
6
+ setup do
7
+ xml_file = File.join(base, '../files/bucket_listing.xml')
8
+ @with_bucket_listing_xml = File.open(xml_file) { |f| f.read }
9
+ xml_file2 = File.join(base, '../files/bucket_listing2.xml')
10
+ @with_bucket_listing_xml2 = File.open(xml_file2) { |f| f.read }
11
+ xml_file3 = File.join(base, '../files/bucket_listing3.xml')
12
+ @with_empty_bucket_listing_xml = File.open(xml_file3) { |f| f.read }
13
+ xml_file4 = File.join(base, '../files/bucket_listing_broken.xml')
14
+ @with_broken_bucket_listing_xml = File.open(xml_file4) { |f| f.read }
15
+ @bucket_listing = BucketListing.new(@with_bucket_listing_xml)
16
+ @bucket_properties = %w(name prefix marker max_keys is_truncated)
17
+ @bucket_property_setters = @bucket_properties.map { |prop| prop + "=" }
18
+ end
19
+
20
+ specify 'can only be created if bucket listing XML supplied' do
21
+ lambda { BucketListing.new }.should.raise ArgumentError
22
+ end
23
+
24
+ specify 'cannot be created from invalid XML' do
25
+ lambda { BucketListing.new(nil) }.should.raise S3Exception::InvalidBucketListing
26
+ end
27
+
28
+ specify 'should recover gracefully from broken bucket listing XML' do
29
+ lambda { BucketListing.new(@with_broken_bucket_listing_xml) }.should.raise S3Exception::InvalidBucketListing
30
+ end
31
+
32
+ specify 'should cope if bucket is empty (i.e. no <Contents> elements)' do
33
+ @bucket_listing.set_listing_xml(@with_empty_bucket_listing_xml)
34
+ end
35
+
36
+ specify 'can return the raw XML used to initialise it' do
37
+ @bucket_listing.listing_xml.should.equal(@with_bucket_listing_xml)
38
+ end
39
+
40
+ specify 'can have the bucket listing XML reset' do
41
+ @bucket_listing.should.respond_to :set_listing_xml
42
+ end
43
+
44
+ specify 'should present bucket metadata as typed properties' do
45
+ @bucket_listing.name.should.equal('testingtesting')
46
+ @bucket_listing.prefix.should.equal('')
47
+ @bucket_listing.marker.should.equal('')
48
+ @bucket_listing.max_keys.should.equal(1000)
49
+ @bucket_listing.is_truncated.should.equal(false)
50
+ @bucket_listing.delimiter.should.be nil
51
+ end
52
+
53
+ specify 'when listing XML is reset, should update all properties correctly' do
54
+ @bucket_listing.set_listing_xml(@with_bucket_listing_xml2)
55
+ @bucket_listing.name.should.equal('testing2')
56
+ @bucket_listing.prefix.should.equal('/home/ell/')
57
+ @bucket_listing.marker.should.equal('')
58
+ @bucket_listing.max_keys.should.equal(100)
59
+ @bucket_listing.delimiter.should.equal('/')
60
+ @bucket_listing.is_truncated.should.equal(false)
61
+ end
62
+
63
+ specify 'should provide private setters for metadata' do
64
+ # all private methods
65
+ methods = @bucket_listing.private_methods.to_set
66
+ # names of the setters
67
+ setters = @bucket_property_setters
68
+ # all methods should be a superset of the private setters
69
+ methods.superset?(setters.to_set).should.equal true
70
+ end
71
+
72
+ specify 'should store resources (<Contents> elements) in a hash' do
73
+ @bucket_listing.contents.size.should_be 10
74
+ first_obj = @bucket_listing.contents['/home/ell/dir1/four.txt']
75
+ first_obj.should_be_instance_of S3Object
76
+ end
77
+
78
+ specify 'should enable access to metadata for a resource by its key' do
79
+ obj = @bucket_listing['/home/ell/dir1/four.txt']
80
+ obj.should_be_instance_of S3Object
81
+ obj.etag.should_equal '24ce59274b89287b3960c184153ac24b'
82
+ end
83
+
84
+ specify 'should be able to build a full representation given full object XML from GET on resource key' do
85
+ fix
86
+ end
87
+
88
+ specify 'should provide easy access to <CommonPrefixes> elements as a hash' do
89
+ fix
90
+ end
91
+ end
92
+
93
+ context 'S3 object' do
94
+ setup do
95
+ @s3_object_xml = File.open(File.join(base, '../files/s3_object.xml')).read
96
+ @s3obj = S3Object.new
97
+ @s3obj.set_from_xml_string(@s3_object_xml)
98
+ end
99
+
100
+ specify 'can be initialised from XML fragment with correct data types' do
101
+ @s3obj.key.should.equal '/home/ell/dir1/four.txt'
102
+ d = @s3obj.last_modified
103
+ [d.year, d.month, d.day, d.hour, d.min, d.sec].should.equal [2006, 8, 19, 22, 53, 29]
104
+ @s3obj.etag.should.equal '24ce59274b89287b3960c184153ac24b'
105
+ @s3obj.size.should.equal 14
106
+ end
107
+
108
+ specify 'should treat the owner as an object in his/her own right' do
109
+ [@s3obj.owner.id, @s3obj.owner.display_name].should.equal \
110
+ ['56efddfead5aa65da942f156fb2b294f44d78fd932d701331edc5fba19620fd4', 'elliotsmith3']
111
+ @s3obj.owner.should_be_instance_of S3User
112
+ end
113
+
114
+ specify 'can be associated with a NamedBucket' do
115
+ fix
116
+ end
117
+
118
+ specify 'can be saved by proxing through the NamedBucket it is associated with' do
119
+ fix
120
+ end
121
+
122
+ specify 'cannot be saved unless associated with a NamedBucket' do
123
+ fix
124
+ end
125
+ end
@@ -0,0 +1,130 @@
1
+ require File.dirname(__FILE__) + '/../s3_test_constants'
2
+
3
+ context 'S3 core' do
4
+
5
+ setup do
6
+ @for_request_method = 'PUT'
7
+ @for_request_path = "/quotes/nelson"
8
+ @for_request_headers = {
9
+ "Content-Md5" => "c8fdb181845a4ca6b8fec737b3581d76",
10
+ "Content-Type" => "text/html",
11
+ "Date" => "Thu, 17 Nov 2005 18:49:58 GMT",
12
+ "X-Amz-Meta-Author" => "foo@bar.com",
13
+ "X-Amz-Magic" => "abracadabra"
14
+ }
15
+
16
+ # create broken request header hash
17
+ @for_incomplete_headers = @for_request_headers.clone.delete_if do |key,value|
18
+ 'Content-Type' == key or 'Date' == key
19
+ end
20
+
21
+ @correct_canonical_string = "PUT\nc8fdb181845a4ca6b8fec737b3581d76\n" +
22
+ "text/html\nThu, 17 Nov 2005 18:49:58 GMT\nx-amz-magic:abracadabra\n" +
23
+ "x-amz-meta-author:foo@bar.com\n/quotes/nelson"
24
+ @correct_signature = "jZNOcbfWmD/A/f3hSvVzXZjM2HU="
25
+ @correct_auth_header = "AWS #{S3Testing::ACCESS_KEY}:#{@correct_signature}"
26
+
27
+ @with_invalid_bucket_name = '/badbucket'
28
+ @with_invalid_bucket_name2 = 'badbucket/'
29
+
30
+ @correct_authenticated_url = "http://s3.amazonaws.com/quotes/nelson?Signature=vjbyPxybdZaNmGa%2ByT272YEAiv4%3D&"+
31
+ "AWSAccessKeyId=44CF9590006BF252F707&Expires=1141889120"
32
+ end
33
+
34
+ specify 'should generate correct canonical strings' do
35
+ generate_canonical_string(@for_request_method, @for_request_path,
36
+ @for_request_headers).should.equal @correct_canonical_string
37
+ end
38
+
39
+ specify 'should generate correct signatures' do
40
+ generate_signature(S3Testing::SECRET_ACCESS_KEY,
41
+ @correct_canonical_string).should.equal @correct_signature
42
+ end
43
+
44
+ specify 'should generate correct auth headers' do
45
+ generate_auth_header_value(@for_request_method, @for_request_path, @for_request_headers,
46
+ S3Testing::ACCESS_KEY, S3Testing::SECRET_ACCESS_KEY).should.equal @correct_auth_header
47
+ end
48
+
49
+ specify 'should not generate auth header if bad HTTP method passed' do
50
+ lambda { generate_auth_header_value('duff', nil, nil, nil, nil) }.should.raise \
51
+ S3Exception::MethodNotAvailable
52
+ end
53
+
54
+ specify 'should not generate auth header if required headers missing' do
55
+ lambda { generate_auth_header_value('PUT', '/', @for_incomplete_headers,
56
+ nil, nil) }.should.raise S3Exception::MissingRequiredHeaders
57
+ end
58
+
59
+ specify 'when generating auth header, should allow addition of Date and Content-Type headers' do
60
+ now = Time.now
61
+
62
+ fixed_headers = add_default_headers(@for_incomplete_headers, :date => now,
63
+ :content_type => 'text/html')
64
+
65
+ fixed_headers['Date'].should.equal now.httpdate
66
+ fixed_headers['Content-Type'].should.equal 'text/html'
67
+ end
68
+
69
+ specify 'should not generate canned ACL header if invalid canned ACL supplied' do
70
+ lambda { canned_acl_header('duff') }.should.raise \
71
+ S3Exception::UnsupportedCannedACL
72
+ end
73
+
74
+ specify 'should correctly add canned ACL headers' do
75
+ new_headers = canned_acl_header('private', { 'Content-Type' => 'text/html' })
76
+ new_headers.should.have(2).keys
77
+ new_headers.keys.should.include 'Content-Type'
78
+ new_headers.keys.should.include 'x-amz-acl'
79
+ new_headers['x-amz-acl'].should.equal 'private'
80
+ end
81
+
82
+ specify 'should set sensible defaults for missing Content-Type and Date headers' do
83
+ fixed_headers = add_default_headers(@for_incomplete_headers)
84
+ fixed_headers['Content-Type'].should.equal ''
85
+ fixed_headers.include?('Date').should.not.be nil
86
+ end
87
+
88
+ specify 'should default to text/plain mimetype for unknown file types' do
89
+ guess_mime_type('hello.madeup').should.equal('text/plain')
90
+ end
91
+
92
+ specify 'should recognise invalid bucket names' do
93
+ lambda { bucket_name_valid?(@with_invalid_bucket_name) }.should.raise \
94
+ S3Exception::MalformedBucketName
95
+ lambda { bucket_name_valid?(@with_invalid_bucket_name2) }.should.raise \
96
+ S3Exception::MalformedBucketName
97
+ end
98
+
99
+ specify 'should return empty string if generating querystring with no key/value pairs' do
100
+ generate_querystring({}).should_equal ''
101
+ end
102
+
103
+ specify 'should correctly format querystring key/value pairs' do
104
+ generate_querystring({'message' => 'Hello world', 'id' => 1, 'page' => '[2,4]'}).should_equal \
105
+ '?message=Hello+world&id=1&page=%5B2%2C4%5D'
106
+ end
107
+
108
+ specify 'should allow symbols as names for querystring variables when generating querystrings' do
109
+ generate_querystring({ :prefix => '/home/ell' }).should.equal('?prefix=%2Fhome%2Fell')
110
+ end
111
+
112
+ specify 'should convert integers to strings when generating querystrings' do
113
+ generate_querystring({ 'max-keys' => 400 }).should.equal('?max-keys=400')
114
+ end
115
+
116
+ specify 'should be able to construct correct URL paths from path fragments' do
117
+ url_join('http://', 'localhost/', 'test').should.equal('http://localhost/test')
118
+ url_join('ftp://test.com', '/public/', '/files').should.equal('ftp://test.com/public/files')
119
+ url_join('http://localhost/', '/index.html').should.equal('http://localhost/index.html')
120
+ url_join('http://', 'localhost', 'test').should.equal('http://localhost/test')
121
+ url_join('http://localhost:8080/test/', '/path/', 'element.html').should.equal('http://localhost:8080/test/path/element.html')
122
+ url_join('/test/', 'index.html').should.equal '/test/index.html'
123
+ end
124
+
125
+ specify 'should generate URLs with authentication parameters' do
126
+ s3_authenticated_url(S3Testing::ACCESS_KEY, S3Testing::SECRET_ACCESS_KEY, 'quotes', 'nelson', \
127
+ 1141889120).should.equal @correct_authenticated_url
128
+ end
129
+
130
+ end