s33r 0.4.2 → 0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (114) hide show
  1. data/examples/cli/instant_download_server.rb +88 -0
  2. data/examples/cli/s3cli.rb +31 -52
  3. data/examples/cli/simple.rb +16 -6
  4. data/examples/fores33r/app/controllers/browser_controller.rb +12 -10
  5. data/examples/fores33r/app/helpers/application_helper.rb +2 -1
  6. data/examples/fores33r/app/views/browser/_upload.rhtml +1 -1
  7. data/examples/fores33r/app/views/browser/index.rhtml +4 -4
  8. data/examples/fores33r/config/environment.rb +5 -3
  9. data/examples/fores33r/log/development.log +2259 -0
  10. data/examples/fores33r/log/mongrel.log +59 -0
  11. data/examples/s3.yaml +2 -6
  12. data/lib/s33r/bucket.rb +103 -0
  13. data/lib/s33r/bucket_listing.rb +33 -76
  14. data/lib/s33r/client.rb +305 -446
  15. data/lib/s33r/networking.rb +197 -0
  16. data/lib/s33r/s33r_exception.rb +29 -18
  17. data/lib/s33r/s33r_http.rb +36 -18
  18. data/lib/s33r/s3_acl.rb +32 -52
  19. data/lib/s33r/s3_logging.rb +117 -0
  20. data/lib/s33r/s3_obj.rb +124 -69
  21. data/lib/s33r/utility.rb +447 -0
  22. data/test/cases/spec_acl.rb +10 -40
  23. data/test/cases/spec_bucket_listing.rb +12 -32
  24. data/test/cases/spec_logging.rb +47 -0
  25. data/test/cases/spec_networking.rb +11 -0
  26. data/test/cases/spec_s3_object.rb +44 -5
  27. data/test/cases/spec_utility.rb +264 -0
  28. data/test/files/acl.xml +0 -6
  29. data/test/files/config.yaml +5 -0
  30. data/test/files/logging_status_disabled.xml +3 -0
  31. data/test/files/logging_status_enabled.xml +7 -0
  32. data/test/test_setup.rb +7 -2
  33. metadata +16 -94
  34. data/examples/cli/acl_x.rb +0 -41
  35. data/examples/cli/logging_x.rb +0 -20
  36. data/examples/fores33r/README +0 -183
  37. data/html/classes/MIME.html +0 -120
  38. data/html/classes/MIME/InvalidContentType.html +0 -119
  39. data/html/classes/MIME/Type.html +0 -1173
  40. data/html/classes/MIME/Types.html +0 -566
  41. data/html/classes/Net.html +0 -108
  42. data/html/classes/Net/HTTPGenericRequest.html +0 -233
  43. data/html/classes/Net/HTTPResponse.html +0 -271
  44. data/html/classes/S33r.html +0 -986
  45. data/html/classes/S33r/BucketListing.html +0 -434
  46. data/html/classes/S33r/Client.html +0 -1575
  47. data/html/classes/S33r/LoggingResource.html +0 -222
  48. data/html/classes/S33r/NamedBucket.html +0 -693
  49. data/html/classes/S33r/OrderlyXmlMarkup.html +0 -165
  50. data/html/classes/S33r/S33rException.html +0 -124
  51. data/html/classes/S33r/S33rException/BucketListingMaxKeysError.html +0 -111
  52. data/html/classes/S33r/S33rException/BucketNotLogTargetable.html +0 -119
  53. data/html/classes/S33r/S33rException/InvalidBucketListing.html +0 -111
  54. data/html/classes/S33r/S33rException/InvalidPermission.html +0 -111
  55. data/html/classes/S33r/S33rException/InvalidS3GroupType.html +0 -111
  56. data/html/classes/S33r/S33rException/MalformedBucketName.html +0 -111
  57. data/html/classes/S33r/S33rException/MethodNotAvailable.html +0 -111
  58. data/html/classes/S33r/S33rException/MissingBucketName.html +0 -111
  59. data/html/classes/S33r/S33rException/MissingRequiredHeaders.html +0 -111
  60. data/html/classes/S33r/S33rException/MissingResource.html +0 -111
  61. data/html/classes/S33r/S33rException/S3FallenOver.html +0 -111
  62. data/html/classes/S33r/S33rException/TryingToPutEmptyResource.html +0 -117
  63. data/html/classes/S33r/S33rException/UnsupportedCannedACL.html +0 -111
  64. data/html/classes/S33r/S33rException/UnsupportedHTTPMethod.html +0 -111
  65. data/html/classes/S33r/S3ACL.html +0 -125
  66. data/html/classes/S33r/S3ACL/ACLDoc.html +0 -521
  67. data/html/classes/S33r/S3ACL/AmazonCustomer.html +0 -168
  68. data/html/classes/S33r/S3ACL/CanonicalUser.html +0 -212
  69. data/html/classes/S33r/S3ACL/Grant.html +0 -403
  70. data/html/classes/S33r/S3ACL/Grantee.html +0 -239
  71. data/html/classes/S33r/S3ACL/Group.html +0 -178
  72. data/html/classes/S33r/S3Object.html +0 -618
  73. data/html/classes/S33r/Sync.html +0 -152
  74. data/html/classes/XML.html +0 -202
  75. data/html/classes/XML/Document.html +0 -125
  76. data/html/classes/XML/Node.html +0 -124
  77. data/html/created.rid +0 -1
  78. data/html/files/CHANGELOG.html +0 -107
  79. data/html/files/MIT-LICENSE.html +0 -129
  80. data/html/files/README_txt.html +0 -259
  81. data/html/files/lib/s33r/bucket_listing_rb.html +0 -101
  82. data/html/files/lib/s33r/builder_rb.html +0 -108
  83. data/html/files/lib/s33r/client_rb.html +0 -111
  84. data/html/files/lib/s33r/core_rb.html +0 -113
  85. data/html/files/lib/s33r/libxml_extensions_rb.html +0 -101
  86. data/html/files/lib/s33r/libxml_loader_rb.html +0 -109
  87. data/html/files/lib/s33r/logging_rb.html +0 -108
  88. data/html/files/lib/s33r/mimetypes_rb.html +0 -120
  89. data/html/files/lib/s33r/named_bucket_rb.html +0 -101
  90. data/html/files/lib/s33r/s33r_exception_rb.html +0 -101
  91. data/html/files/lib/s33r/s33r_http_rb.html +0 -108
  92. data/html/files/lib/s33r/s3_acl_rb.html +0 -108
  93. data/html/files/lib/s33r/s3_obj_rb.html +0 -108
  94. data/html/files/lib/s33r/sync_rb.html +0 -101
  95. data/html/files/lib/s33r_rb.html +0 -101
  96. data/html/fr_class_index.html +0 -66
  97. data/html/fr_file_index.html +0 -44
  98. data/html/fr_method_index.html +0 -183
  99. data/html/index.html +0 -24
  100. data/html/rdoc-style.css +0 -208
  101. data/lib/s33r/core.rb +0 -296
  102. data/lib/s33r/logging.rb +0 -43
  103. data/lib/s33r/named_bucket.rb +0 -148
  104. data/lib/s33r/sync.rb +0 -13
  105. data/test/cases/spec_all_buckets.rb +0 -28
  106. data/test/cases/spec_client.rb +0 -101
  107. data/test/cases/spec_core.rb +0 -128
  108. data/test/cases/spec_namedbucket.rb +0 -46
  109. data/test/cases/spec_sync.rb +0 -34
  110. data/test/files/all_buckets.xml +0 -21
  111. data/test/files/client_config.yml +0 -5
  112. data/test/files/namedbucket_config.yml +0 -8
  113. data/test/files/namedbucket_config2.yml +0 -8
  114. data/test/test_bucket_setup.rb +0 -41
@@ -0,0 +1,117 @@
1
+ require 'rubygems'
2
+ require_gem 'builder'
3
+ require File.join(File.dirname(__FILE__), 'libxml_loader')
4
+ require File.join(File.dirname(__FILE__), 's3_acl')
5
+
6
+ module S33r
7
+ module S3Logging
8
+
9
+ # For manipulating logging directives on resources
10
+ # (see http://docs.amazonwebservices.com/AmazonS3/2006-03-01/LoggingHowTo.html).
11
+ #
12
+ # Creating a LoggingResource instance using new and no arguments will generate a "blank" instance;
13
+ # this can be put to the ?logging URL for a resource to remove logging from it.
14
+ #
15
+ # To set a Bucket up for logging, create a LoggingResource with the correct log_target and
16
+ # log_prefix settings and put that to the ?logging URL for a bucket.
17
+ class LoggingResource
18
+ attr_reader :log_target, :log_prefix
19
+
20
+ # +log_target+ is the bucket to put logs into.
21
+ # +log_prefix+ is the prefix for log files put into the +log_target+ bucket.
22
+ def initialize(log_target=nil, log_prefix=nil)
23
+ @log_target = log_target
24
+ @log_prefix = log_prefix
25
+ end
26
+
27
+ # Generate a BucketLoggingStatus XML document for putting to the ?logging
28
+ # URL for a resource.
29
+ def to_xml
30
+ xml_str = ""
31
+ xml = Builder::XmlMarkup.new(:target => xml_str, :indent => 0)
32
+
33
+ xml.instruct!
34
+
35
+ # BucketLoggingStatus XML.
36
+ xml.BucketLoggingStatus({"xmlns" => RESPONSE_NAMESPACE_URI}) {
37
+ unless @log_target.nil? and @log_prefix.nil?
38
+ xml.LoggingEnabled {
39
+ xml.TargetBucket @log_target
40
+ xml.TargetPrefix @log_prefix
41
+ }
42
+ end
43
+ }
44
+
45
+ xml_str
46
+ end
47
+
48
+ # Convert XML from S3 response into a LoggingResource
49
+ #
50
+ #-- TODO: tests
51
+ def self.from_xml(logging_xml)
52
+ return nil if logging_xml.nil?
53
+
54
+ logging_xml = S33r.remove_namespace(logging_xml)
55
+ doc = XML.get_xml_doc(logging_xml)
56
+
57
+ log_target = doc.xget('//TargetBucket')
58
+ log_prefix = doc.xget('//TargetPrefix')
59
+
60
+ self.new(log_target, log_prefix)
61
+ end
62
+
63
+ end
64
+ end
65
+
66
+ # Extensions to the S3ACL module to cover logging.
67
+ module S3ACL
68
+ class Policy
69
+ # Does the ACL make the associated resource available as a log target?
70
+ def log_targetable?
71
+ log_target_grants = Grant.log_target_grants
72
+ log_target_grants.each { |g| return false if !grants.include?(g) }
73
+ return true
74
+ end
75
+
76
+ # Add permissions to an instances which give READ_ACL
77
+ # and WRITE permissions to the LogDelivery group. Used
78
+ # to enable a bucket as a logging destination.
79
+ #
80
+ # Returns true if grants added, false otherwise
81
+ # (if already a log target).
82
+ def add_log_target_grants
83
+ if log_targetable?
84
+ return false
85
+ else
86
+ Grant.log_target_grants.each { |g| add_grant(g) }
87
+ return true
88
+ end
89
+ end
90
+
91
+ # Remove log target ACLs from the document.
92
+ #
93
+ # Returns true if all log target grants were removed;
94
+ # false otherwise.
95
+ #
96
+ # NB even if this method returns false, that doesn't mean
97
+ # the bucket is still a log target. Use log_targetable? to check
98
+ # whether a bucket can be used as a log target.
99
+ def remove_log_target_grants
100
+ ok = true
101
+ Grant.log_target_grants.each { |g| ok = ok and remove_grant(g) }
102
+ ok
103
+ end
104
+ end
105
+
106
+ class Grant
107
+ # Generator for a grant which gives the LogDelivery group
108
+ # write and read_acl permissions on a bucket.
109
+ #
110
+ # Returns an array with the two required Grant instances.
111
+ def Grant.log_target_grants
112
+ log_delivery_group = Group.new(:log_delivery)
113
+ [Grant.new(log_delivery_group, :read_acl), Grant.new(log_delivery_group, :write)]
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/s33r/s3_obj.rb CHANGED
@@ -1,64 +1,105 @@
1
1
  require 'date'
2
+ require File.join(File.dirname(__FILE__), 's3_acl')
3
+ require File.join(File.dirname(__FILE__), 'client')
2
4
 
3
5
  # Representation of an object stored in a bucket.
4
6
  module S33r
5
- class S3Object
6
- attr_accessor :key, :last_modified, :etag, :size, :owner, :storage_class, :value, :named_bucket,
7
- :content_type, :mime_type
7
+ class S3Object < Client
8
+ attr_accessor :key, :last_modified, :etag, :size, :owner, :storage_class, :value,
9
+ :content_type, :render_as_attachment
8
10
 
9
- # Metadata set by x-amz-meta- style headers. Note that the bit after x-amz-meta-
11
+ # Name of bucket this object is attached to.
12
+ attr_reader :bucket
13
+
14
+ # Metadata to set with x-amz-meta- style headers. Note that the bit after x-amz-meta-
10
15
  # is stored for each key, rather than the full key.
11
- attr_accessor :meta
16
+ attr_accessor :amz_meta
17
+ alias :meta :amz_meta
12
18
 
13
- def initialize(key, metadata={}, amz_meta={}, value=nil)
19
+ # +options+ can include:
20
+ # * <tt>:bucket => Bucket</tt>: Bucket this object is attached to.
21
+ # * <tt>:metadata => Hash</tt>: metadata to use in building the object.
22
+ # * <tt>:amz_meta => Hash</tt>: metadata specific to Amazon.
23
+ def initialize(key, value=nil, options={})
14
24
  @key = key
15
- @meta = amz_meta
25
+
16
26
  @value = value
27
+ @content_type = 'text/plain'
28
+ @render_as_attachment = false
29
+ @amz_meta = options[:amz_meta] || {}
30
+
31
+ set_bucket(options[:bucket])
32
+
33
+ metadata = options[:metadata] || {}
17
34
  set_properties(metadata) unless metadata.empty?
18
35
  end
19
36
 
37
+ # Set a bucket instance as the default bucket for this object.
38
+ def set_bucket(bucket_instance)
39
+ if bucket_instance
40
+ @bucket = bucket_instance
41
+ set_options(@bucket.settings)
42
+ extend(InBucket)
43
+ end
44
+ end
45
+ alias :bucket= :set_bucket
46
+
20
47
  # Set the properties of the object from some metadata name-value pairs.
21
48
  #
22
49
  # +metadata+ is a hash of properties and their values, used to set the
23
50
  # corresponding properties on the object.
24
- #
25
- # +value+ is the data associated with the object on S3.
26
51
  def set_properties(metadata)
27
52
  # required properties
28
53
  @etag = metadata[:etag].gsub("\"", "") if metadata[:etag]
29
54
  @last_modified = DateTime.parse(metadata[:last_modified]) if metadata[:last_modified]
30
55
  @size = metadata[:size].to_i if metadata[:size]
56
+ @render_as_attachment = metadata[:render_as_attachment] || false
31
57
 
32
58
  # only set if creating object from XML (not available otherwise)
33
- @owner = metadata[:owner]
59
+ @owner ||= metadata[:owner]
34
60
 
35
61
  # only set if creating object from HTTP response
36
- @content_type = metadata[:content_type]
62
+ @content_type = metadata[:content_type] if metadata[:content_type]
37
63
  end
38
64
 
39
65
  # To create an object which reads the content in from a file;
40
- # this is not very efficient - it's actually better to use NamedBucket.put_file,
41
- # as this will stream out of a file to S3, rather than load the file in
42
- # memory first.
43
- def self.from_file(key, filename)
66
+ # you can then save the object to its associated bucket (if you like).
67
+ def self.from_file(filename, options={})
44
68
  mime_type = guess_mime_type(filename)
45
69
  content_type = mime_type.simplified
46
70
  value = File.open(filename).read
47
- self.new(key, { :content_type => content_type }, {}, value)
71
+ key = options[:key] || filename
72
+
73
+ options.merge!(:metadata => {:content_type => content_type})
74
+ self.new(key, value, options)
75
+ end
76
+
77
+ # Create a new instance from a string.
78
+ def self.from_text(key, text, options={})
79
+ content_type = 'text/plain'
80
+
81
+ options.merge!(:metadata => {:content_type => content_type})
82
+ self.new(key, text, options)
48
83
  end
49
84
 
50
85
  # Set properties of the object from an XML string.
51
86
  #
52
87
  # +xml_str+ should be a string representing a full XML document,
53
88
  # containing a <Contents> element as its root element.
54
- def self.from_xml_string(xml_str)
89
+ def self.from_xml_string(xml_str, options={})
55
90
  self.from_xml_node(XML.get_xml_doc(xml_str))
56
91
  end
57
92
 
58
- # Create a new instance from an XML document.
59
- def self.from_xml_node(doc)
93
+ # Create a new instance from an XML document;
94
+ # N.B. this instance will have no value associated with it (yet).
95
+ # Call the load method to populate it.
96
+ #
97
+ # +options+ is passed to the constructor.
98
+ def self.from_xml_node(doc, options={})
60
99
  metadata = self.parse_xml_node(doc)
61
- self.new(metadata[:key], metadata)
100
+
101
+ options.merge!(:metadata => metadata)
102
+ self.new(metadata[:key], nil, options)
62
103
  end
63
104
 
64
105
  # Get properties of the object from an XML document, e.g. as returned in a bucket listing.
@@ -66,7 +107,6 @@ module S33r
66
107
  # +doc+: XML::Document instance to parse to get properties for this object.
67
108
  #
68
109
  # Returns the metadata relating to the object, as stored on S3.
69
- #-- TODO: include amz-meta elements
70
110
  def self.parse_xml_node(doc)
71
111
  metadata = {}
72
112
  metadata[:key] = doc.xget('Key')
@@ -88,18 +128,20 @@ module S33r
88
128
  # do a HEAD for that.
89
129
  #
90
130
  # +key+ is the key for the resource (not part of the response).
131
+ # +resp+ is a Net::HTTPResponse instance to parse.
132
+ # +options+ is passed through to the constructor (see initialize).
91
133
  #
92
134
  # Note that if the resp returns nil, a blank object is created.
93
- def self.from_response(key, resp)
135
+ def self.from_response(key, resp, options={})
94
136
  result = self.parse_response(resp)
95
137
  if result
96
138
  metadata, amz_meta, value = result
139
+ options.merge!(:metadata => metadata, :amz_meta => amz_meta)
97
140
  else
98
- metadata = {}
99
- amz_meta = {}
100
141
  value = nil
101
142
  end
102
- self.new(key, metadata, amz_meta, value)
143
+
144
+ self.new(key, value, options)
103
145
  end
104
146
 
105
147
  # Parse the response returned by GET on a resource key
@@ -120,67 +162,80 @@ module S33r
120
162
  metadata[:size] = resp_headers['content-length'][0]
121
163
  metadata[:content_type] = resp_headers['content-type'][0]
122
164
 
165
+ content_disposition = resp_headers['content-disposition']
166
+ if content_disposition
167
+ content_disposition = content_disposition[0]
168
+ if /^attachment/ =~ content_disposition
169
+ metadata[:render_as_attachment] = true
170
+ end
171
+ end
172
+
123
173
  # x-amz-meta- response headers.
124
174
  interesting_header = Regexp.new(METADATA_PREFIX)
125
- amz_meta = {}
175
+ new_amz_meta = {}
126
176
  resp.each_header do |key, value|
127
- amz_meta[key.gsub(interesting_header, '')] = value if interesting_header =~ key
177
+ new_amz_meta[key.gsub(interesting_header, '')] = value if interesting_header =~ key
128
178
  end
129
179
 
130
180
  # The actual content of the S3 object.
131
181
  value = resp.body
132
182
 
133
- [metadata, amz_meta, value]
183
+ [metadata, new_amz_meta, value]
134
184
  else
135
185
  nil
136
186
  end
137
187
  end
138
188
 
139
- # Load content into this object from S3; will perform
140
- # an HTTP request to "refresh" the object (providing the object
141
- # has an association with a bucket it can use for piggybacking).
142
- #
143
- # Returns false if value cannot be retrieved.
144
- def load
145
- if @named_bucket and @named_bucket.key_exists?(@key)
146
- resp = @named_bucket.get_raw(@key)
147
- if resp.ok?
148
- @value = resp.body
149
- @content_type = resp.to_hash['content-type']
150
- return true
151
- else
152
- return false
153
- end
154
- else
155
- return false
156
- end
189
+ # Set metadata on the object.
190
+ def []=(key, value)
191
+ amz_meta[key] = value
157
192
  end
158
193
 
159
- # Remove this object from its associated NamedBucket.
160
- #
161
- # Returns false if this object is not associated with a bucket;
162
- # otherwise returns the response from S3.
163
- def delete
164
- if @named_bucket.nil?
165
- return false
166
- else
167
- @named_bucket.delete_resource(@key)
168
- end
194
+ # Get metadata on the object.
195
+ def [](key)
196
+ amz_meta[key]
197
+ end
198
+ end
199
+
200
+ module InBucket
201
+ # Send requests using the bucket's options.
202
+ def request_defaults
203
+ bucket.request_defaults.merge(:key => key)
204
+ end
205
+
206
+ # Fetch an object's metadata and content from S3.
207
+ def fetch(options={})
208
+ resp = do_get(options)
209
+
210
+ metadata, amz_meta, data = S3Object.parse_response(resp)
211
+ @amz_meta = amz_meta
212
+ @value = data
213
+ set_properties(metadata)
214
+ true
169
215
  end
170
216
 
171
- # Save this object back into its bucket.
172
- #
173
- # Only works if the object has an associated NamedBucket;
174
- # returns false if it doesn't.
175
- def save
176
- if @named_bucket.nil?
177
- return false
178
- else
179
- headers = {}
180
- headers["Content-Type"] = @content_type || ''
181
- headers = metadata_headers(headers, meta)
182
- @named_bucket.put_stream(@value, @key, headers)
183
- end
217
+ # Save an object to S3.
218
+ def save(options={})
219
+ bucket.put(self, options)
184
220
  end
221
+
222
+ # Delete the object from S3.
223
+ def delete(options={})
224
+ bucket.delete(key, options)
225
+ end
226
+
227
+ # Change the object's name on S3.
228
+ def rename(new_key, options={})
229
+ # Delete the object from S3.
230
+ bucket.delete(key, options)
231
+
232
+ # Set the new key.
233
+ self.key = new_key
234
+ options[:key] = new_key
235
+
236
+ save(options)
237
+ end
238
+ alias :mv :rename
239
+
185
240
  end
186
241
  end
@@ -0,0 +1,447 @@
1
+ require 'base64'
2
+ require 'time'
3
+ require 'yaml'
4
+ require 'erb'
5
+ require 'cgi'
6
+
7
+ base = File.dirname(__FILE__)
8
+ require File.join(base, 'libxml_loader')
9
+ require File.join(base, 'libxml_extensions')
10
+
11
+ # Module to handle S3 operations which don't require an internet connection,
12
+ # i.e. data validation and request-building operations;
13
+ # also holds all the constants relating to S3.
14
+ #
15
+ # Parts of this code are heavily based on Amazon's code. Here's their license:
16
+ #
17
+ # This software code is made available "AS IS" without warranties of any
18
+ # kind. You may copy, display, modify and redistribute the software
19
+ # code either by itself or as incorporated into your code; provided that
20
+ # you do not remove any proprietary notices. Your use of this software
21
+ # code is at your own risk and you waive any claim against Amazon
22
+ # Digital Services, Inc. or its affiliates with respect to your use of
23
+ # this software code. (c) 2006 Amazon Digital Services, Inc. or its
24
+ # affiliates.
25
+ module S33r
26
+ HOST = 's3.amazonaws.com'
27
+ PORT = 443
28
+ NON_SSL_PORT = 80
29
+ METADATA_PREFIX = 'x-amz-meta-'
30
+ # Size of each chunk (in bytes) to be sent per request when putting files (1Mb).
31
+ DEFAULT_CHUNK_SIZE = 1048576
32
+ AWS_HEADER_PREFIX = 'x-amz-'
33
+ AWS_AUTH_HEADER_VALUE = "AWS %s:%s"
34
+ INTERESTING_HEADERS = ['content-md5', 'content-type', 'date']
35
+ # Headers which must be included with every request to S3.
36
+ REQUIRED_HEADERS = ['Content-Type', 'Date']
37
+ # Canned ACLs made available by S3.
38
+ CANNED_ACLS = ['private', 'public-read', 'public-read-write', 'authenticated-read']
39
+ # HTTP methods which S3 will respond to.
40
+ METHOD_VERBS = ['GET', 'PUT', 'HEAD', 'DELETE']
41
+ # Maximum number which can be passed in max-keys parameter when GETting bucket list.
42
+ BUCKET_LIST_MAX_MAX_KEYS = 1000
43
+ # Default number of seconds an authenticated URL will last for (15 minutes).
44
+ DEFAULT_EXPIRY_SECS = 60 * 15
45
+ # Number of years to use for expiry date when :expires is set to :far_flung_future.
46
+ FAR_FUTURE = 20
47
+ # The namespace used for response body XML documents.
48
+ RESPONSE_NAMESPACE_URI = "http://s3.amazonaws.com/doc/2006-03-01/"
49
+
50
+ # Permissions which can be set within a <Grant>
51
+ # (see http://docs.amazonwebservices.com/AmazonS3/2006-03-01/UsingPermissions.html).
52
+ #
53
+ # NB I've missed out the WRITE_ACP permission as this is functionally
54
+ # equivalent to FULL_CONTROL.
55
+ PERMISSIONS = {
56
+ :read => 'READ', # permission to read
57
+ :write => 'WRITE', # permission to write
58
+ :read_acl => 'READ_ACP', # permission to read ACL settings
59
+ :all => 'FULL_CONTROL' # do anything
60
+ }
61
+
62
+ # Used for generating ACL XML documents.
63
+ NAMESPACE = 'xsi'
64
+ NAMESPACE_URI = 'http://www.w3.org/2001/XMLSchema-instance'
65
+ GRANTEE_TYPES = {
66
+ :amazon_customer => 'AmazonCustomerByEmail',
67
+ :canonical_user => 'CanonicalUser',
68
+ :group => 'Group'
69
+ }
70
+ S3_GROUP_TYPES = {
71
+ :all_users => 'global/AllUsers',
72
+ :authenticated_users => 'global/AuthenticatedUsers',
73
+ :log_delivery => 's3/LogDelivery'
74
+ }
75
+ GROUP_ACL_URI_BASE = 'http://acs.amazonaws.com/groups/'
76
+
77
+ include S3Exception
78
+
79
+ # Load YAML config. file for S33r operations. The config. file looks like this:
80
+ #
81
+ # :include: test/files/config.yaml
82
+ #
83
+ # The +options+ section of the YAML file is optional, and can be used
84
+ # to add application-specific settings for your application.
85
+ #
86
+ # Note that the loader also runs the config. file through ERB, so you can
87
+ # add dynamic blocks of ERB (Ruby) code into your files.
88
+ #
89
+ # +config_file+ is the path to the configuration file.
90
+ #
91
+ # Returns a <tt>[config, options]</tt>, where +config+ is a hash of standard S33r
92
+ # options (:access, :secret), and +options+ is a hash of general application options.
93
+ #
94
+ # The keys for both hashes are converted from strings into symbols.
95
+ def self.load_config(config_file)
96
+ config = YAML::load(ERB.new(IO.read(config_file)).result)
97
+
98
+ options = config.delete('options')
99
+ options = S33r.keys_to_symbols(options)
100
+
101
+ config = S33r.keys_to_symbols(config)
102
+
103
+ [config, options]
104
+ end
105
+
106
+ # Build canonical string for signing;
107
+ # modified (slightly) from the Amazon sample code.
108
+ #
109
+ # * +method+ is one of the available METHOD_VERBS.
110
+ # * +path+ is the path part of the URL to generate the canonical string for.
111
+ # * +headers+ is a hash of headers which are going to be sent with the request.
112
+ # * +expires+ is the expiry time set in the querystring for authenticated URLs:
113
+ # if supplied, it is used for the +date+ header.
114
+ def generate_canonical_string(method, path, headers={}, expires=nil)
115
+ interesting_headers = {}
116
+ headers.each do |key, value|
117
+ lk = key.downcase
118
+ if (INTERESTING_HEADERS.include?(lk) or lk =~ /^#{AWS_HEADER_PREFIX}/o)
119
+ interesting_headers[lk] = value
120
+ end
121
+ end
122
+
123
+ # These fields get empty strings if they don't exist.
124
+ interesting_headers['content-type'] ||= ''
125
+ interesting_headers['content-md5'] ||= ''
126
+
127
+ # If you're using expires for query string auth, then it trumps date.
128
+ if not expires.nil?
129
+ interesting_headers['date'] = expires
130
+ end
131
+
132
+ buf = "#{method}\n"
133
+ interesting_headers.sort { |a, b| a[0] <=> b[0] }.each do |key, value|
134
+ if key =~ /^#{AWS_HEADER_PREFIX}/o
135
+ buf << "#{key}:#{value}\n"
136
+ else
137
+ buf << "#{value}\n"
138
+ end
139
+ end
140
+
141
+ # Ignore everything after the question mark...
142
+ buf << path.gsub(/\?.*$/, '')
143
+
144
+ # ...unless there is an acl, logging or torrent parameter
145
+ if path =~ /[&?]acl($|&|=)/
146
+ buf << '?acl'
147
+ elsif path =~ /[&?]torrent($|&|=)/
148
+ buf << '?torrent'
149
+ elsif path =~ /[&?]logging($|&|=)/
150
+ buf << '?logging'
151
+ end
152
+
153
+ buf
154
+ end
155
+
156
+ # Get the value for the AWS authentication header.
157
+ def generate_auth_header_value(method, path, headers, aws_access_key, aws_secret_access_key)
158
+ raise MethodNotAllowed, "Method %s not available" % method if !METHOD_VERBS.include?(method)
159
+
160
+ # check the headers needed for authentication have been set
161
+ missing_headers = REQUIRED_HEADERS - headers.keys
162
+ if !(missing_headers.empty?)
163
+ raise MissingRequiredHeaders,
164
+ "Headers required for AWS auth value are missing: " + missing_headers.join(', ')
165
+ end
166
+
167
+ raise KeysIncomplete, "Access key or secret access key nil" \
168
+ if aws_access_key.nil? or aws_secret_access_key.nil?
169
+
170
+ # get the AWS header
171
+ canonical_string = generate_canonical_string(method, path, headers)
172
+ signature = generate_signature(aws_secret_access_key, canonical_string)
173
+ AWS_AUTH_HEADER_VALUE % [aws_access_key, signature]
174
+ end
175
+
176
+ # Encode the given string with the aws_secret_access_key, by taking the
177
+ # hmac sha1 sum, and then base64 encoding it.
178
+ def generate_signature(aws_secret_access_key, str)
179
+ digest = OpenSSL::HMAC::digest(OpenSSL::Digest::Digest.new("SHA1"), aws_secret_access_key, str)
180
+ Base64.encode64(digest).strip
181
+ end
182
+
183
+ # Build the headers required with every S3 request (Date and Content-Type);
184
+ # options hash can contain extra header settings;
185
+ # +:date+ and +:content_type+ are required headers, and set to
186
+ # defaults if not supplied.
187
+ def default_headers(existing_headers, options={})
188
+ headers = {}
189
+
190
+ # which default headers required by AWS are missing?
191
+ missing_headers = REQUIRED_HEADERS - existing_headers.keys
192
+
193
+ if missing_headers.include?('Content-Type')
194
+ headers['Content-Type'] = options[:content_type] || ''
195
+ end
196
+
197
+ if missing_headers.include?('Date')
198
+ date = options[:date] || Time.now
199
+ headers['Date'] = date.httpdate
200
+ end
201
+
202
+ headers
203
+ end
204
+
205
+ # Add metadata headers, correctly prefixing them first,
206
+ # e.g. you might do metadata_headers({'myname' => 'elliot', 'myage' => 36})
207
+ # to add two headers to the request:
208
+ #
209
+ # x-amz-meta-myname: elliot
210
+ # x-amz-meta-myage: 36
211
+ #
212
+ # Keys shouldn't have spaces; they can also be represented using symbols.
213
+ #
214
+ # Returns metadata headers appended, with both keys and values as strings.
215
+ def metadata_headers(metadata={})
216
+ headers = {}
217
+ unless metadata.empty?
218
+ metadata.each { |key, value| headers[METADATA_PREFIX + key.to_s] = value.to_s }
219
+ end
220
+ headers
221
+ end
222
+
223
+ # Content transfer headers: set Content-Type, Content-Transfer-Encoding
224
+ # and Content-Disposition headers.
225
+ #
226
+ # <tt>content_type</tt>: content type string to send in the header, e.g. 'text/html'.
227
+ #
228
+ # +key+ is the key for the object: used as the filename if the file is downloaded; defaults to
229
+ # 'download' if not set. If you use a path (e.g. '/home/you/photos/me.jpg'), just the last part
230
+ # ('me.jpg') is used as the name of the download file.
231
+ #
232
+ # <tt>render_as_attachment</tt>: set to true if you want to add a content disposition header
233
+ # which enables the object to be downloaded, rather than shown inline, when fetched by a browser.
234
+ def content_headers(content_type, key='download', render_as_attachment=false)
235
+ headers = {}
236
+
237
+ headers['Content-Type'] = content_type || 'text/plain'
238
+ mime_type = MIME::Types[content_type][0]
239
+ if mime_type
240
+ headers['Content-Transfer-Encoding'] = 'binary' if mime_type.binary?
241
+ end
242
+ if render_as_attachment
243
+ headers['Content-Disposition'] = "attachment; filename=#{File.basename(key)}"
244
+ end
245
+
246
+ headers
247
+ end
248
+
249
+ # Get a canned ACL setter header.
250
+ def canned_acl_header(canned_acl)
251
+ headers = {}
252
+ unless canned_acl.nil?
253
+ unless CANNED_ACLS.include?(canned_acl)
254
+ raise S3Exception::UnsupportedCannedACL, "The canned ACL #{canned_acl} is not supported"
255
+ end
256
+ headers[AWS_HEADER_PREFIX + 'acl'] = canned_acl
257
+ end
258
+ headers
259
+ end
260
+
261
+ # Guess a file's mime type.
262
+ # If the mime_type for a file cannot be guessed, "text/plain" is used.
263
+ def guess_mime_type(file_name)
264
+ mime_type = MIME::Types.type_for(file_name)[0]
265
+ mime_type ||= MIME::Types['text/plain'][0]
266
+ mime_type
267
+ end
268
+
269
+ # Ensure that a bucket_name is well-formed (no leading or trailing slash).
270
+ def bucket_name_valid?(bucket_name)
271
+ if ('/' == bucket_name[0,1] || '/' == bucket_name[-1,1])
272
+ raise S3Exception::MalformedBucketName, "Bucket name cannot have a leading or trailing slash"
273
+ end
274
+ end
275
+
276
+ # Convert a hash of name/value pairs to querystring variables.
277
+ # Name for a variable can be a string or symbol.
278
+ def generate_querystring(pairs=nil)
279
+ str = ''
280
+ pairs ||= {}
281
+ if pairs.size > 0
282
+ name_value_pairs = pairs.map do |key, value|
283
+ if value.nil?
284
+ key
285
+ else
286
+ "#{key}=#{CGI::escape(value.to_s)}"
287
+ end
288
+ end
289
+ str += name_value_pairs.join('&')
290
+ end
291
+ str
292
+ end
293
+
294
+ # Returns the path for this bucket and key.
295
+ #
296
+ # +options+:
297
+ # * <tt>:bucket => 'my-bucket'</tt>: get a path which includes the bucket (unless
298
+ # :subdomain => true is also passed in)
299
+ # * <tt>:key => 'my-key'</tt>: get a path including a key
300
+ # * <tt>:querystring => {'acl' => nil, 'page' => 2, ...}</tt>: adds a querystring to path
301
+ # (when generating a signature for a URL, any '?acl' or '?logging' parameters must be
302
+ # included as part of the path before hashing)
303
+ # * <tt>:subdomain => true</tt>: don't include the bucket name in the path.
304
+ # * <tt>:acl => true</tt>: append ?acl to the front of the querystring.
305
+ # * <tt>:logging => true</tt>: append ?logging to the start of the querystring.
306
+ def s3_path(options={})
307
+ bucket = options[:bucket]
308
+ key = options[:key]
309
+
310
+ qstring_pairs = options[:querystring] || {}
311
+ if options[:acl]
312
+ qstring_pairs = {:acl => nil}.merge(qstring_pairs)
313
+ elsif options[:logging]
314
+ qstring_pairs = {:logging => nil}.merge(qstring_pairs)
315
+ end
316
+
317
+ qstring = generate_querystring(qstring_pairs)
318
+
319
+ path = '/'
320
+ path += (bucket + '/') if bucket and !options[:subdomain]
321
+ path += key if key
322
+ path += '?' + qstring unless '' == qstring
323
+ path
324
+ end
325
+
326
+ # Build a URL for a bucket or object on S3.
327
+ #
328
+ # +options+ are passed through to either s3_authenticated_url or s3_public_url
329
+ # (if :authenticated, :access and :secret options are passed, s3_authenticated_url is used):
330
+ # * <tt>:bucket => 'my-bucket'</tt>: bucket the URL is for.
331
+ # * <tt>:key => 'my-key'</tt>: the key to produce a URL for.
332
+ # * <tt>:use_ssl => true</tt>: return an https:// URL.
333
+ # * <tt>:subdomain => true</tt>: use :bucket as the subdomain
334
+ # to produce a bucket URL like 'elliot.s3.amazonaws.com' instead of
335
+ # 's3.amazonaws.com/elliot'. Note that this
336
+ # is NOT SUPPORTED for authenticated requests or SSL requests.
337
+ # * <tt>:path => '/bucket/key'</tt>: include given path on end of URL; if not set,
338
+ # a path is generated from any bucket and/or key given
339
+ # * <tt>:access => 'aws access key'</tt>: Generate authenticated URL.
340
+ # * <tt>:secret => 'aws secret access key'</tt>: Generate authenticated URL.
341
+ # * <tt>:authenticated => true</tt>: Produce an authenticated URL.
342
+ # * <tt>:querystring => {'name' => 'value', 'test' => nil, ...}</tt>: add querystring
343
+ # parameters to the URL; NB any keys with a nil value are added to the querystring
344
+ # as keys without values. Note that querystring parameters are just appended in the order they
345
+ # are returned by the map iterator for a hash.
346
+ # * <tt>:acl => true</tt>: append ?acl to the front of the querystring.
347
+ # * <tt>:logging => true</tt>: append ?logging to the start of the querystring.
348
+ def s3_url(options={})
349
+ # Turn off the subdomain option if using SSL.
350
+ options[:subdomain] = false if options[:use_ssl]
351
+
352
+ access = options[:access]
353
+ secret = options[:secret]
354
+ if access and secret and options[:authenticated]
355
+ # Turn off the subdomain option (it doesn't work with authenticated URLs).
356
+ options[:subdomain] = false
357
+ s3_authenticated_url(access, secret, options)
358
+ else
359
+ s3_public_url(options)
360
+ end
361
+ end
362
+
363
+ # Public readable URL for a bucket and resource.
364
+ #
365
+ # +options+ are passed through from s3_url; only :access and :secret are irrelevant of the options
366
+ # available to s3_url.
367
+ #
368
+ # Note that if a :path option is not set, a path is generated from any :bucket and/or :path
369
+ # parameters supplied.
370
+ def s3_public_url(options)
371
+ scheme = options[:use_ssl] ? 'https' : 'http'
372
+ path = options[:path]
373
+ path ||= s3_path(options)
374
+ host = HOST
375
+ host = (options[:bucket] + "." + host ) if options[:subdomain]
376
+ "#{scheme}://" + host + path
377
+ end
378
+
379
+ # Generate a get-able URL for an S3 resource key which passes authentication in querystring. Note
380
+ # that this will correctly generate authenticated URLs for logging and ACL resources.
381
+ #
382
+ # +options+ are passed through to s3_path and s3_url; an :expires option is also available:
383
+ # * <tt>:expires => <date time></tt>: when the URL expires (seconds since the epoch); S33r.parse_expiry
384
+ # is used to generate a suitable value from a date/time string, or you can use an int. Use :far_flung_future
385
+ # to get some time in the distant future. Defaults to current time + S33r::DEFAULT_EXPIRY_SECS.
386
+ def s3_authenticated_url(aws_access_key, aws_secret_access_key, options={})
387
+ raise KeysIncomplete, "You must supply both an AWS access key and secret access key to create \
388
+ an authenticated URL" if aws_access_key.nil? or aws_secret_access_key.nil?
389
+
390
+ path = s3_path(options)
391
+ expires = S33r.parse_expiry(options[:expires])
392
+
393
+ canonical_string = generate_canonical_string('GET', path, {}, expires)
394
+ signature = generate_signature(aws_secret_access_key, canonical_string)
395
+
396
+ querystring = generate_querystring({'Signature' => signature, 'Expires' => expires,
397
+ 'AWSAccessKeyId' => aws_access_key })
398
+
399
+ options[:path] = path
400
+ base_url = s3_public_url(options)
401
+ /\?/ =~ base_url ? base_url += '&' : base_url += '?'
402
+ base_url += querystring
403
+ base_url
404
+ end
405
+
406
+ # Return the hash +hsh+ with keys converted to symbols.
407
+ def self.keys_to_symbols(hsh)
408
+ symbolised = {}
409
+ hsh.each_pair do |key, value|
410
+ symbolised[key.to_sym] = value
411
+ end
412
+ symbolised
413
+ end
414
+
415
+ # Parse an expiry date into seconds since the epoch.
416
+ #
417
+ # +expires+ can be set to :far_flung_future to get a time FAR_FUTURE years in the future;
418
+ # or to a specific date (parseable by ParseDate); or to an integer
419
+ # representing seconds since the epoch. If you leave it blank, you'll get
420
+ # the current time + DEFAULT_EXPIRY_SECS.
421
+ #
422
+ # Returns an integer representing seconds since the epoch.
423
+ def self.parse_expiry(expires=nil)
424
+ unless expires.kind_of?(Integer)
425
+ if expires.is_a?(String)
426
+ expires = Time.parse(expires).to_i
427
+ else
428
+ base_expires = Time.now.to_i
429
+ if :far_flung_future == expires
430
+ # 50 years (same as forever in computer terms)
431
+ expires = (base_expires + (60 * 60 * 24 * 365.25 * FAR_FUTURE)).to_i
432
+ else
433
+ # default to DEFAULT_EXPIRY_SECS seconds from now if expires not set
434
+ expires = base_expires + DEFAULT_EXPIRY_SECS
435
+ end
436
+ end
437
+ end
438
+ expires
439
+ end
440
+
441
+ # Remove the namespace declaration from S3 XML response bodies (libxml
442
+ # isn't fond of it).
443
+ def self.remove_namespace(xml_in)
444
+ namespace = S33r::RESPONSE_NAMESPACE_URI.gsub('/', '\/')
445
+ xml_in.gsub(/ xmlns="#{namespace}"/, '')
446
+ end
447
+ end