seanwalbran-scashin133-s3 0.3.11
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +7 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +23 -0
- data/LICENSE +20 -0
- data/README.rdoc +79 -0
- data/Rakefile +21 -0
- data/lib/s3.rb +27 -0
- data/lib/s3/bucket.rb +182 -0
- data/lib/s3/buckets_extension.rb +26 -0
- data/lib/s3/connection.rb +225 -0
- data/lib/s3/exceptions.rb +110 -0
- data/lib/s3/object.rb +253 -0
- data/lib/s3/objects_extension.rb +37 -0
- data/lib/s3/parser.rb +52 -0
- data/lib/s3/request.rb +31 -0
- data/lib/s3/service.rb +87 -0
- data/lib/s3/signature.rb +245 -0
- data/lib/s3/version.rb +3 -0
- data/s3.gemspec +29 -0
- data/test/bucket_test.rb +215 -0
- data/test/connection_test.rb +214 -0
- data/test/object_test.rb +205 -0
- data/test/service_test.rb +111 -0
- data/test/signature_test.rb +218 -0
- data/test/test_helper.rb +3 -0
- metadata +156 -0
@@ -0,0 +1,110 @@
|
|
1
|
+
module S3
|
2
|
+
module Error
|
3
|
+
|
4
|
+
# All responses with a code between 300 and 599 that contain an
|
5
|
+
# <Error></Error> body are wrapped in an ErrorResponse which
|
6
|
+
# contains an Error object. This Error class generates a custom
|
7
|
+
# exception with the name of the xml Error and its message. All
|
8
|
+
# such runtime generated exception classes descend from
|
9
|
+
# ResponseError and contain the ErrorResponse object so that all
|
10
|
+
# code that makes a request can rescue ResponseError and get
|
11
|
+
# access to the ErrorResponse.
|
12
|
+
class ResponseError < StandardError
|
13
|
+
attr_reader :response
|
14
|
+
|
15
|
+
# Creates new S3::ResponseError.
|
16
|
+
#
|
17
|
+
# ==== Parameters
|
18
|
+
# * <tt>message</tt> - what went wrong
|
19
|
+
# * <tt>response</tt> - Net::HTTPResponse object or nil
|
20
|
+
def initialize(message, response)
|
21
|
+
@response = response
|
22
|
+
super(message)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Factory for all other Exception classes in module, each for
|
26
|
+
# every error response available from AmazonAWS
|
27
|
+
#
|
28
|
+
# ==== Parameters
|
29
|
+
# * <tt>code</tt> - Code name of exception
|
30
|
+
#
|
31
|
+
# ==== Returns
|
32
|
+
# Descendant of ResponseError suitable for that exception code
|
33
|
+
# or ResponseError class if no class found
|
34
|
+
def self.exception(code)
|
35
|
+
S3::Error.const_get(code)
|
36
|
+
rescue NameError
|
37
|
+
ResponseError
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
#:stopdoc:
|
42
|
+
|
43
|
+
class AccessDenied < ResponseError; end
|
44
|
+
class AccountProblem < ResponseError; end
|
45
|
+
class AmbiguousGrantByEmailAddress < ResponseError; end
|
46
|
+
class BadDigest < ResponseError; end
|
47
|
+
class BucketAlreadyExists < ResponseError; end
|
48
|
+
class BucketAlreadyOwnedByYou < ResponseError; end
|
49
|
+
class BucketNotEmpty < ResponseError; end
|
50
|
+
class CredentialsNotSupported < ResponseError; end
|
51
|
+
class CrossLocationLoggingProhibited < ResponseError; end
|
52
|
+
class EntityTooSmall < ResponseError; end
|
53
|
+
class EntityTooLarge < ResponseError; end
|
54
|
+
class ExpiredToken < ResponseError; end
|
55
|
+
class IncompleteBody < ResponseError; end
|
56
|
+
class IncorrectNumberOfFilesInPostRequestPOST < ResponseError; end
|
57
|
+
class InlineDataTooLarge < ResponseError; end
|
58
|
+
class InternalError < ResponseError; end
|
59
|
+
class InvalidAccessKeyId < ResponseError; end
|
60
|
+
class InvalidAddressingHeader < ResponseError; end
|
61
|
+
class InvalidArgument < ResponseError; end
|
62
|
+
class InvalidBucketName < ResponseError; end
|
63
|
+
class InvalidDigest < ResponseError; end
|
64
|
+
class InvalidLocationConstraint < ResponseError; end
|
65
|
+
class InvalidPayer < ResponseError; end
|
66
|
+
class InvalidPolicyDocument < ResponseError; end
|
67
|
+
class InvalidRange < ResponseError; end
|
68
|
+
class InvalidSecurity < ResponseError; end
|
69
|
+
class InvalidSOAPRequest < ResponseError; end
|
70
|
+
class InvalidStorageClass < ResponseError; end
|
71
|
+
class InvalidTargetBucketForLogging < ResponseError; end
|
72
|
+
class InvalidToken < ResponseError; end
|
73
|
+
class InvalidURI < ResponseError; end
|
74
|
+
class KeyTooLong < ResponseError; end
|
75
|
+
class MalformedACLError < ResponseError; end
|
76
|
+
class MalformedACLError < ResponseError; end
|
77
|
+
class MalformedPOSTRequest < ResponseError; end
|
78
|
+
class MalformedXML < ResponseError; end
|
79
|
+
class MaxMessageLengthExceeded < ResponseError; end
|
80
|
+
class MaxPostPreDataLengthExceededErrorYour < ResponseError; end
|
81
|
+
class MetadataTooLarge < ResponseError; end
|
82
|
+
class MethodNotAllowed < ResponseError; end
|
83
|
+
class MissingAttachment < ResponseError; end
|
84
|
+
class MissingContentLength < ResponseError; end
|
85
|
+
class MissingRequestBodyError < ResponseError; end
|
86
|
+
class MissingSecurityElement < ResponseError; end
|
87
|
+
class MissingSecurityHeader < ResponseError; end
|
88
|
+
class NoLoggingStatusForKey < ResponseError; end
|
89
|
+
class NoSuchBucket < ResponseError; end
|
90
|
+
class NoSuchKey < ResponseError; end
|
91
|
+
class NotImplemented < ResponseError; end
|
92
|
+
class NotSignedUp < ResponseError; end
|
93
|
+
class OperationAborted < ResponseError; end
|
94
|
+
class PermanentRedirect < ResponseError; end
|
95
|
+
class PreconditionFailed < ResponseError; end
|
96
|
+
class Redirect < ResponseError; end
|
97
|
+
class RequestIsNotMultiPartContent < ResponseError; end
|
98
|
+
class RequestTimeout < ResponseError; end
|
99
|
+
class RequestTimeTooSkewed < ResponseError; end
|
100
|
+
class RequestTorrentOfBucketError < ResponseError; end
|
101
|
+
class SignatureDoesNotMatch < ResponseError; end
|
102
|
+
class SlowDown < ResponseError; end
|
103
|
+
class TemporaryRedirect < ResponseError; end
|
104
|
+
class TokenRefreshRequired < ResponseError; end
|
105
|
+
class TooManyBuckets < ResponseError; end
|
106
|
+
class UnexpectedContent < ResponseError; end
|
107
|
+
class UnresolvableGrantByEmailAddress < ResponseError; end
|
108
|
+
class UserKeyMustBeSpecified < ResponseError; end
|
109
|
+
end
|
110
|
+
end
|
data/lib/s3/object.rb
ADDED
@@ -0,0 +1,253 @@
|
|
1
|
+
module S3
|
2
|
+
|
3
|
+
# Class responsible for handling objects stored in S3 buckets
|
4
|
+
class Object
|
5
|
+
include Parser
|
6
|
+
extend Forwardable
|
7
|
+
|
8
|
+
attr_accessor :content_type, :content_disposition, :content_encoding, :cache_control
|
9
|
+
attr_reader :last_modified, :etag, :size, :bucket, :key, :acl, :storage_class, :metadata
|
10
|
+
attr_writer :content
|
11
|
+
|
12
|
+
def_instance_delegators :bucket, :name, :service, :bucket_request, :vhost?, :host, :path_prefix
|
13
|
+
def_instance_delegators :service, :protocol, :port, :secret_access_key
|
14
|
+
private_class_method :new
|
15
|
+
|
16
|
+
# Compares the object with other object. Returns true if the key
|
17
|
+
# of the objects are the same, and both have the same buckets (see
|
18
|
+
# Bucket equality)
|
19
|
+
def ==(other)
|
20
|
+
other.equal?(self) || (other.instance_of?(self.class) && self.key == other.key && self.bucket == other.bucket)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns full key of the object: e.g. <tt>bucket-name/object/key.ext</tt>
|
24
|
+
def full_key
|
25
|
+
[name, key].join("/")
|
26
|
+
end
|
27
|
+
|
28
|
+
# Assigns a new +key+ to the object, raises ArgumentError if given
|
29
|
+
# key is not valid key name
|
30
|
+
def key=(key)
|
31
|
+
raise ArgumentError.new("Invalid key name: #{key}") unless key_valid?(key)
|
32
|
+
@key ||= key
|
33
|
+
end
|
34
|
+
|
35
|
+
# Assigns a new ACL to the object. Please note that ACL is not
|
36
|
+
# retrieved from the server and set to "public-read" by default.
|
37
|
+
#
|
38
|
+
# ==== Example
|
39
|
+
# object.acl = :public_read
|
40
|
+
def acl=(acl)
|
41
|
+
@acl = acl.to_s.gsub("_", "-") if acl
|
42
|
+
end
|
43
|
+
|
44
|
+
# Assigns a new storage class (RRS) to the object. Please note
|
45
|
+
# that the storage class is not retrieved from the server and set
|
46
|
+
# to "STANDARD" by default.
|
47
|
+
#
|
48
|
+
# ==== Example
|
49
|
+
# object.storage_class = :reduced_redundancy
|
50
|
+
def storage_class=(storage_class)
|
51
|
+
@storage_class = storage_class.to_s.upcase if storage_class
|
52
|
+
end
|
53
|
+
|
54
|
+
# Retrieves the object from the server. Method is used to download
|
55
|
+
# object information only (content type, size and so on). It does
|
56
|
+
# NOT download the content of the object (use the #content method
|
57
|
+
# to do it).
|
58
|
+
def retrieve
|
59
|
+
object_headers
|
60
|
+
self
|
61
|
+
end
|
62
|
+
|
63
|
+
# Retrieves the object from the server, returns true if the object
|
64
|
+
# exists or false otherwise. Uses #retrieve method, but catches
|
65
|
+
# S3::Error::NoSuchKey exception and returns false when it happens
|
66
|
+
def exists?
|
67
|
+
retrieve
|
68
|
+
true
|
69
|
+
rescue Error::NoSuchKey
|
70
|
+
false
|
71
|
+
end
|
72
|
+
|
73
|
+
# Downloads the content of the object, and caches it. Pass true to
|
74
|
+
# clear the cache and download the object again.
|
75
|
+
def content(reload = false)
|
76
|
+
return @content if defined?(@content) and not reload
|
77
|
+
get_object
|
78
|
+
@content
|
79
|
+
end
|
80
|
+
|
81
|
+
# Saves the object, returns true if successfull.
|
82
|
+
def save
|
83
|
+
put_object
|
84
|
+
true
|
85
|
+
end
|
86
|
+
|
87
|
+
# Copies the file to another key and/or bucket.
|
88
|
+
#
|
89
|
+
# ==== Options
|
90
|
+
# * <tt>:key</tt> - New key to store object in
|
91
|
+
# * <tt>:bucket</tt> - New bucket to store object in (instance of
|
92
|
+
# S3::Bucket)
|
93
|
+
# * <tt>:acl</tt> - ACL of the copied object (default:
|
94
|
+
# "public-read")
|
95
|
+
# * <tt>:content_type</tt> - Content type of the copied object
|
96
|
+
# (default: "application/octet-stream")
|
97
|
+
def copy(options = {})
|
98
|
+
copy_object(options)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Destroys the file on the server
|
102
|
+
def destroy
|
103
|
+
delete_object
|
104
|
+
true
|
105
|
+
end
|
106
|
+
|
107
|
+
# Returns Object's URL using protocol specified in service,
|
108
|
+
# e.g. <tt>http://domain.com.s3.amazonaws.com/key/with/path.extension</tt>
|
109
|
+
def url
|
110
|
+
URI.escape("#{protocol}#{host}/#{path_prefix}#{key}")
|
111
|
+
end
|
112
|
+
|
113
|
+
# Returns a temporary url to the object that expires on the
|
114
|
+
# timestamp given. Defaults to one hour expire time.
|
115
|
+
def temporary_url(expires_at = Time.now + 3600)
|
116
|
+
signature = Signature.generate_temporary_url_signature(:bucket => name,
|
117
|
+
:resource => key,
|
118
|
+
:expires_at => expires_at,
|
119
|
+
:secret_access_key => secret_access_key)
|
120
|
+
|
121
|
+
"#{url}?AWSAccessKeyId=#{self.bucket.service.access_key_id}&Expires=#{expires_at.to_i.to_s}&Signature=#{signature}"
|
122
|
+
end
|
123
|
+
|
124
|
+
# Returns Object's CNAME URL (without <tt>s3.amazonaws.com</tt>
|
125
|
+
# suffix) using protocol specified in Service,
|
126
|
+
# e.g. <tt>http://domain.com/key/with/path.extension</tt>. (you
|
127
|
+
# have to set the CNAME in your DNS before using the CNAME URL
|
128
|
+
# schema).
|
129
|
+
def cname_url
|
130
|
+
URI.escape("#{protocol}#{name}/#{key}") if bucket.vhost?
|
131
|
+
end
|
132
|
+
|
133
|
+
def inspect #:nodoc:
|
134
|
+
"#<#{self.class}:/#{name}/#{key}>"
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
attr_writer :last_modified, :etag, :size, :original_key, :bucket
|
140
|
+
|
141
|
+
def copy_object(options = {})
|
142
|
+
key = options[:key] or raise ArgumentError, "No key given"
|
143
|
+
raise ArgumentError.new("Invalid key name: #{key}") unless key_valid?(key)
|
144
|
+
bucket = options[:bucket] || self.bucket
|
145
|
+
|
146
|
+
headers = {}
|
147
|
+
|
148
|
+
headers[:x_amz_acl] = options[:acl] || acl || "public-read"
|
149
|
+
headers[:content_type] = options[:content_type] || content_type || "application/octet-stream"
|
150
|
+
headers[:content_encoding] = options[:content_encoding] if options[:content_encoding]
|
151
|
+
headers[:content_disposition] = options[:content_disposition] if options[:content_disposition]
|
152
|
+
headers[:cache_control] = options[:cache_control] if options[:cache_control]
|
153
|
+
headers[:x_amz_copy_source] = full_key
|
154
|
+
headers[:x_amz_metadata_directive] = options[:replace] == false ? "COPY" : "REPLACE"
|
155
|
+
headers[:x_amz_copy_source_if_match] = options[:if_match] if options[:if_match]
|
156
|
+
headers[:x_amz_copy_source_if_none_match] = options[:if_none_match] if options[:if_none_match]
|
157
|
+
headers[:x_amz_copy_source_if_unmodified_since] = options[:if_modified_since] if options[:if_modified_since]
|
158
|
+
headers[:x_amz_copy_source_if_modified_since] = options[:if_unmodified_since] if options[:if_unmodified_since]
|
159
|
+
|
160
|
+
response = bucket.send(:bucket_request, :put, :path => key, :headers => headers)
|
161
|
+
object_attributes = parse_copy_object_result(response.body)
|
162
|
+
|
163
|
+
object = Object.send(:new, bucket, object_attributes.merge(:key => key, :size => size))
|
164
|
+
object.acl = response["x-amz-acl"]
|
165
|
+
object.content_type = response["content-type"]
|
166
|
+
object.content_encoding = response["content-encoding"]
|
167
|
+
object.content_disposition = response["content-disposition"]
|
168
|
+
object.cache_control = response["cache-control"]
|
169
|
+
object
|
170
|
+
end
|
171
|
+
|
172
|
+
def get_object(options = {})
|
173
|
+
response = object_request(:get, options)
|
174
|
+
parse_headers(response)
|
175
|
+
end
|
176
|
+
|
177
|
+
def object_headers(options = {})
|
178
|
+
response = object_request(:head, options)
|
179
|
+
parse_headers(response)
|
180
|
+
rescue Error::ResponseError => e
|
181
|
+
if e.response.code.to_i == 404
|
182
|
+
raise Error::ResponseError.exception("NoSuchKey").new("The specified key does not exist.", nil)
|
183
|
+
else
|
184
|
+
raise e
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def put_object
|
189
|
+
response = object_request(:put, :body => content, :headers => dump_headers)
|
190
|
+
parse_headers(response)
|
191
|
+
end
|
192
|
+
|
193
|
+
def delete_object(options = {})
|
194
|
+
object_request(:delete)
|
195
|
+
end
|
196
|
+
|
197
|
+
def initialize(bucket, options = {})
|
198
|
+
self.bucket = bucket
|
199
|
+
self.key = options[:key]
|
200
|
+
self.last_modified = options[:last_modified]
|
201
|
+
self.etag = options[:etag]
|
202
|
+
self.size = options[:size]
|
203
|
+
self.cache_control = options[:cache_control]
|
204
|
+
end
|
205
|
+
|
206
|
+
def object_request(method, options = {})
|
207
|
+
bucket_request(method, options.merge(:path => key))
|
208
|
+
end
|
209
|
+
|
210
|
+
def last_modified=(last_modified)
|
211
|
+
@last_modified = Time.parse(last_modified) if last_modified
|
212
|
+
end
|
213
|
+
|
214
|
+
def etag=(etag)
|
215
|
+
@etag = etag[1..-2] if etag
|
216
|
+
end
|
217
|
+
|
218
|
+
def key_valid?(key)
|
219
|
+
if (key.nil? or key.empty? or key =~ %r#//#)
|
220
|
+
false
|
221
|
+
else
|
222
|
+
true
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def dump_headers
|
227
|
+
headers = {}
|
228
|
+
headers[:x_amz_acl] = @acl || "public-read"
|
229
|
+
headers[:x_amz_storage_class] = @storage_class || "STANDARD"
|
230
|
+
headers[:content_type] = @content_type || "application/octet-stream"
|
231
|
+
headers[:content_encoding] = @content_encoding if @content_encoding
|
232
|
+
headers[:content_disposition] = @content_disposition if @content_disposition
|
233
|
+
headers[:cache_control] = @cache_control if @cache_control
|
234
|
+
headers
|
235
|
+
end
|
236
|
+
|
237
|
+
def parse_headers(response)
|
238
|
+
@metadata = response.to_hash.select { |k, v| k.to_s.start_with?("x-amz-meta") }
|
239
|
+
self.etag = response["etag"] if response.key?("etag")
|
240
|
+
self.content_type = response["content-type"] if response.key?("content-type")
|
241
|
+
self.content_disposition = response["content-disposition"] if response.key?("content-disposition")
|
242
|
+
self.cache_control = response["cache-control"] if response.key?("cache-control")
|
243
|
+
self.content_encoding = response["content-encoding"] if response.key?("content-encoding")
|
244
|
+
self.last_modified = response["last-modified"] if response.key?("last-modified")
|
245
|
+
if response.key?("content-range")
|
246
|
+
self.size = response["content-range"].sub(/[^\/]+\//, "").to_i
|
247
|
+
else
|
248
|
+
self.size = response["content-length"]
|
249
|
+
self.content = response.body
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module S3
|
2
|
+
module ObjectsExtension
|
3
|
+
# Builds the object in the bucket with given key
|
4
|
+
def build(key)
|
5
|
+
Object.send(:new, proxy_owner, :key => key)
|
6
|
+
end
|
7
|
+
|
8
|
+
# Finds first object with given name or raises the exception if
|
9
|
+
# not found
|
10
|
+
def find_first(name)
|
11
|
+
object = build(name)
|
12
|
+
object.retrieve
|
13
|
+
end
|
14
|
+
alias :find :find_first
|
15
|
+
|
16
|
+
# Finds the objects in the bucket.
|
17
|
+
#
|
18
|
+
# ==== Options
|
19
|
+
# * <tt>:prefix</tt> - Limits the response to keys which begin
|
20
|
+
# with the indicated prefix
|
21
|
+
# * <tt>:marker</tt> - Indicates where in the bucket to begin
|
22
|
+
# listing
|
23
|
+
# * <tt>:max_keys</tt> - The maximum number of keys you'd like
|
24
|
+
# to see
|
25
|
+
# * <tt>:delimiter</tt> - Causes keys that contain the same
|
26
|
+
# string between the prefix and the first occurrence of the
|
27
|
+
# delimiter to be rolled up into a single result element
|
28
|
+
def find_all(options = {})
|
29
|
+
proxy_owner.send(:list_bucket, options)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Destroys all keys in the bucket
|
33
|
+
def destroy_all
|
34
|
+
proxy_target.each { |object| object.destroy }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/s3/parser.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
module S3
|
2
|
+
module Parser
|
3
|
+
include REXML
|
4
|
+
|
5
|
+
def rexml_document(xml)
|
6
|
+
xml.force_encoding(::Encoding::UTF_8) if xml.respond_to? :force_encoding
|
7
|
+
Document.new(xml)
|
8
|
+
end
|
9
|
+
|
10
|
+
def parse_list_all_my_buckets_result(xml)
|
11
|
+
names = []
|
12
|
+
rexml_document(xml).elements.each("ListAllMyBucketsResult/Buckets/Bucket/Name") { |e| names << e.text }
|
13
|
+
names
|
14
|
+
end
|
15
|
+
|
16
|
+
def parse_location_constraint(xml)
|
17
|
+
rexml_document(xml).elements["LocationConstraint"].text
|
18
|
+
end
|
19
|
+
|
20
|
+
def parse_list_bucket_result(xml)
|
21
|
+
objects_attributes = []
|
22
|
+
rexml_document(xml).elements.each("ListBucketResult/Contents") do |e|
|
23
|
+
object_attributes = {}
|
24
|
+
object_attributes[:key] = e.elements["Key"].text
|
25
|
+
object_attributes[:etag] = e.elements["ETag"].text
|
26
|
+
object_attributes[:last_modified] = e.elements["LastModified"].text
|
27
|
+
object_attributes[:size] = e.elements["Size"].text
|
28
|
+
objects_attributes << object_attributes
|
29
|
+
end
|
30
|
+
objects_attributes
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_copy_object_result(xml)
|
34
|
+
object_attributes = {}
|
35
|
+
document = rexml_document(xml)
|
36
|
+
object_attributes[:etag] = document.elements["CopyObjectResult/ETag"].text
|
37
|
+
object_attributes[:last_modified] = document.elements["CopyObjectResult/LastModified"].text
|
38
|
+
object_attributes
|
39
|
+
end
|
40
|
+
|
41
|
+
def parse_error(xml)
|
42
|
+
document = rexml_document(xml)
|
43
|
+
code = document.elements["Error/Code"].text
|
44
|
+
message = document.elements["Error/Message"].text
|
45
|
+
[code, message]
|
46
|
+
end
|
47
|
+
|
48
|
+
def parse_is_truncated xml
|
49
|
+
rexml_document(xml).elements["ListBucketResult/IsTruncated"].text =='true'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|