File Coverage

blib/lib/Amazon/S3/Thin.pm
Criterion Covered Total %
statement 157 186 84.4
branch 57 80 71.2
condition 14 29 48.2
subroutine 28 32 87.5
pod 15 15 100.0
total 271 342 79.2


line stmt bran cond sub pod time code
1             package Amazon::S3::Thin;
2 8     8   84914 use 5.008001;
  8         63  
3 8     8   41 use strict;
  8         12  
  8         144  
4 8     8   32 use warnings;
  8         16  
  8         191  
5 8     8   37 use Carp;
  8         11  
  8         626  
6 8     8   4827 use LWP::UserAgent;
  8         353524  
  8         271  
7 8     8   66 use Digest::MD5;
  8         13  
  8         303  
8 8     8   4017 use Encode;
  8         99487  
  8         583  
9 8     8   3316 use Amazon::S3::Thin::Resource;
  8         19  
  8         225  
10 8     8   2845 use Amazon::S3::Thin::Credentials;
  8         19  
  8         16037  
11              
12             our $VERSION = '0.31';
13              
14             my $METADATA_PREFIX = 'x-amz-meta-';
15             my $MAIN_HOST = 's3.amazonaws.com';
16              
17             sub new {
18 18     18 1 9270 my $class = shift;
19 18         36 my $self = shift;
20              
21             # If we have an explicitly-configured credential provider then use that here, otherwise
22             # existing behaviour will be followed
23 18 100 66     109 if ($self->{credential_provider} and $self->{credential_provider} eq 'env') {
    50 33        
24 1         6 $self->{credentials} = Amazon::S3::Thin::Credentials->from_env;
25             }
26             elsif ($self->{credential_provider} and $self->{credential_provider} eq 'metadata') {
27 0         0 $self->{credentials} = Amazon::S3::Thin::Credentials->from_metadata($self);
28             }
29             else {
30             # check existence of credentials
31 17 100       203 croak "No aws_access_key_id" unless $self->{aws_access_key_id};
32 16 100       116 croak "No aws_secret_access_key" unless $self->{aws_secret_access_key};
33              
34             # wrap credentials
35             $self->{credentials} = Amazon::S3::Thin::Credentials->new(
36             $self->{aws_access_key_id},
37             $self->{aws_secret_access_key},
38             $self->{aws_session_token},
39 15         111 );
40 15         34 delete $self->{aws_access_key_id};
41 15         24 delete $self->{aws_secret_access_key};
42 15         26 delete $self->{aws_session_token};
43             }
44 16         27 delete $self->{credential_provider};
45              
46 16         28 bless $self, $class;
47              
48 16 100       46 $self->secure(0) unless defined $self->secure;
49 16 100       39 $self->ua($self->_default_ua) unless defined $self->ua;
50 16 100       41 $self->debug(0) unless defined $self->debug;
51 16 100       37 $self->virtual_host(0) unless defined $self->virtual_host;
52              
53 16 100       55 $self->{signature_version} = 4 unless defined $self->{signature_version};
54 16 50 66     100 if ($self->{signature_version} == 4 && ! $self->{region}) {
55 0         0 croak "Please set region when you use signature v4";
56             }
57              
58 16         49 $self->{signer} = $self->_load_signer($self->{signature_version});
59 16         112 return $self;
60             }
61              
62             sub _load_signer {
63 16     16   24 my $self = shift;
64 16         22 my $version = shift;
65 16         43 my $signer_class = "Amazon::S3::Thin::Signer::V$version";
66 16 50       865 eval "require $signer_class" or die $@;
67              
68 16 100       103 if ($version == 2) {
    50          
69 3         22 return $signer_class->new($self->{credentials}, $MAIN_HOST);
70             } elsif ($version == 4) {
71 13         73 return $signer_class->new($self->{credentials}, $self->{region});
72             }
73             }
74              
75              
76             sub _default_ua {
77 8     8   11 my $self = shift;
78              
79 8         39 my $ua = LWP::UserAgent->new(
80             keep_alive => 10,
81             requests_redirectable => [qw(GET HEAD DELETE PUT)],
82             );
83 8         9832 $ua->timeout(30);
84 8         126 $ua->env_proxy;
85 8         8261 return $ua;
86             }
87              
88             # Accessors
89              
90             sub secure {
91 56     56 1 87 my $self = shift;
92 56 100       123 if (@_) {
93 15         34 $self->{secure} = shift;
94             } else {
95 41         177 return $self->{secure};
96             }
97             }
98              
99             sub debug {
100 35     35 1 64 my $self = shift;
101 35 100       75 if (@_) {
102 16         31 $self->{debug} = shift;
103             } else {
104 19         83 return $self->{debug};
105             }
106             }
107              
108             sub ua {
109 43     43 1 70 my $self = shift;
110 43 100       97 if (@_) {
111 8         14 $self->{ua} = shift;
112             } else {
113 35         139 return $self->{ua};
114             }
115             }
116              
117             sub virtual_host {
118 53     53 1 78 my $self = shift;
119 53 100       103 if (@_) {
120 15         29 $self->{virtual_host} = shift;
121             } else {
122 38         130 return $self->{virtual_host};
123             }
124             }
125              
126             sub _send {
127 17     17   37 my ($self, $request) = @_;
128 17 50       56 warn "[Request]\n" , $request->as_string if $self->{debug};
129 17         48 my $response = $self->ua->request($request);
130 17 50       219 warn "[Response]\n" , $response->as_string if $self->{debug};
131 17         40 return $response;
132             }
133              
134             # API calls
135              
136             sub get_object {
137 5     5 1 3483 my ($self, $bucket, $key, $headers) = @_;
138 5         18 my $request = $self->_compose_request('GET', $self->_resource($bucket, $key), $headers);
139 5         24 return $self->_send($request);
140             }
141              
142             sub head_object {
143 2     2 1 12 my ($self, $bucket, $key) = @_;
144 2         4 my $request = $self->_compose_request('HEAD', $self->_resource($bucket, $key));
145 2         12 return $self->_send($request);
146             }
147              
148             sub delete_object {
149 0     0 1 0 my ($self, $bucket, $key) = @_;
150 0         0 my $request = $self->_compose_request('DELETE', $self->_resource($bucket, $key));
151 0         0 return $self->_send($request);
152             }
153              
154             sub copy_object {
155 4     4 1 5650 my ($self, $src_bucket, $src_key, $dst_bucket, $dst_key, $headers) = @_;
156 4   100     24 $headers ||= {};
157 4         16 $headers->{'x-amz-copy-source'} = $src_bucket . "/" . $src_key;
158 4         17 my $request = $self->_compose_request('PUT', $self->_resource($dst_bucket, $dst_key), $headers);
159 4         27 my $res = $self->_send($request);
160              
161             # XXX: Since the COPY request might return error response in 200 OK, we'll rewrite the status code to 500 for convenience
162             # ref http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectCOPY.html
163             # ref https://github.com/boto/botocore/blob/4e9b4419ec018716ab1a3fe1587fbdc3cfef200e/botocore/handlers.py#L77-L120
164 4 100       24 if ($self->_looks_like_special_case_error($res)) {
165 2         48 $res->code(500);
166             }
167 4         67 return $res;
168             }
169              
170             sub _looks_like_special_case_error {
171 4     4   11 my ($self, $res) = @_;
172 4   33     17 return $res->code == 200 && (length $res->content == 0 || $res->content =~ //);
173             }
174              
175             sub put_object {
176 2     2 1 27 my ($self, $bucket, $key, $content, $headers) = @_;
177 2 50 33     15 croak 'must specify key' unless $key && length $key;
178              
179 2 50       13 if ($headers->{acl_short}) {
180 0         0 $self->_validate_acl_short($headers->{acl_short});
181 0         0 $headers->{'x-amz-acl'} = $headers->{acl_short};
182 0         0 delete $headers->{acl_short};
183             }
184              
185 2 50       11 if (ref($content) eq 'SCALAR') {
186 0   0     0 $headers->{'Content-Length'} ||= -s $$content;
187 0         0 $content = _content_sub($$content);
188             }
189             else {
190 2   33     11 $headers->{'Content-Length'} ||= length $content;
191             }
192              
193 2 50       22 if (ref($content)) {
194             # TODO
195             # I do not understand what it is :(
196             #
197             # return $self->_send_request_expect_nothing_probed('PUT',
198             # $self->_resource($bucket, $key), $headers, $content);
199             #
200 0         0 die "unable to handle reference";
201             }
202             else {
203 2         25 my $request = $self->_compose_request('PUT', $self->_resource($bucket, $key), $headers, $content);
204 2         15 return $self->_send($request);
205             }
206             }
207              
208             sub list_objects {
209 2     2 1 9086 my ($self, $bucket, $opt) = @_;
210 2 50       8 croak 'must specify bucket' unless $bucket;
211 2   50     7 $opt ||= {};
212              
213 2         5 my $query_string;
214 2 50       6 if (%$opt) {
215             $query_string = join('&',
216 2         12 map { $_ . "=" . Amazon::S3::Thin::Resource->urlencode($opt->{$_}) } sort keys %$opt);
  4         394  
217             }
218              
219 2         78 my $resource = $self->_resource($bucket, undef, $query_string);
220 2         9 my $request = $self->_compose_request('GET', $resource);
221 2         14 my $response = $self->_send($request);
222 2         8 return $response;
223             }
224              
225             sub delete_multiple_objects {
226 2     2 1 2560 my ($self, $bucket, @keys) = @_;
227              
228 2         9 my $content = _build_xml_for_delete(@keys);
229             # XXX: specify an empty string with `delete` query for calculating signature correctly in AWS::Signature4
230 2         9 my $resource = $self->_resource($bucket, undef, 'delete=');
231 2         44 my $request = $self->_compose_request(
232             'POST',
233             $resource,
234             {
235             'Content-MD5' => Digest::MD5::md5_base64($content) . '==',
236             'Content-Length' => length $content,
237             },
238             $content
239             );
240 2         12 my $response = $self->_send($request);
241 2         8 return $response;
242             }
243              
244             sub _build_xml_for_delete {
245 2     2   5 my (@keys) = @_;
246              
247 2         5 my $content = 'true';
248              
249 2         5 foreach my $k (@keys) {
250 4         404 $content .= ''
251             . Encode::encode('UTF-8', $k)
252             . '';
253             }
254 2         65 $content .= '';
255              
256 2         6 return $content;
257             }
258              
259             # Operations on Buckets
260              
261             sub put_bucket {
262 0     0 1 0 my ($self, $bucket, $headers) = @_;
263             #
264             # https://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
265 0         0 my $region = $self->{region};
266 0         0 my $content ;
267 0 0       0 if ($region eq "us-east-1") {
268 0         0 $content = "";
269             } else {
270 0         0 my $location_constraint = "$region";
271 0         0 $content = <<"EOT";
272             $location_constraint
273             EOT
274             }
275              
276 0         0 my $request = $self->_compose_request('PUT', $self->_resource($bucket), $headers, $content);
277 0         0 return $self->_send($request);
278             }
279              
280             sub delete_bucket {
281 0     0 1 0 my ($self, $bucket) = @_;
282 0         0 my $request = $self->_compose_request('DELETE', $self->_resource($bucket));
283 0         0 return $self->_send($request);
284             }
285              
286             sub generate_presigned_post {
287 9     9 1 8181 my ($self, $bucket, $key, $fields, $conditions, $expires_in) = @_;
288              
289 9 100       124 croak 'must specify bucket' unless defined $bucket;
290 8 100       78 croak 'must specify key' unless defined $key;
291              
292 7 100       20 if ($self->{signature_version} == 4) {
293 6         13 my $resource = $self->_resource($bucket);
294 6 50       14 my $protocol = $self->secure ? 'https' : 'http';
295              
296             return {
297             ($self->virtual_host
298             ? (url => $resource->to_virtual_hosted_style_url($protocol))
299             : (url => $resource->to_path_style_url($protocol, $self->{region}))),
300             fields => $self->{signer}->_generate_presigned_post(
301 6 50       16 $bucket, $key, $fields, $conditions, $expires_in
302             ),
303             };
304             } else {
305 1         171 croak 'generate_presigned_post is only supported on signature v4';
306             }
307             }
308              
309             sub _resource {
310 23     23   86 my ($self, $bucket, $key, $query_string) = @_;
311 23         147 return Amazon::S3::Thin::Resource->new($bucket, $key, $query_string);
312             }
313              
314             sub _validate_acl_short {
315 0     0   0 my ($self, $policy_name) = @_;
316              
317 0 0       0 if (!grep({$policy_name eq $_}
  0         0  
318             qw(private public-read public-read-write authenticated-read)))
319             {
320 0         0 croak "$policy_name is not a supported canned access policy";
321             }
322             }
323              
324             # make the HTTP::Request object
325             sub _compose_request {
326 17     17   46 my ($self, $method, $resource, $headers, $content, $metadata) = @_;
327 17 50       40 croak 'must specify method' unless $method;
328 17 50       36 croak 'must specify resource' unless defined $resource;
329 17 50       46 if (ref $resource ne 'Amazon::S3::Thin::Resource') {
330 0         0 croak 'resource must be an instance of Amazon::S3::Thin::Resource';
331             }
332 17   100     58 $headers ||= {};
333 17   50     71 $metadata ||= {};
334              
335             # generates an HTTP::Headers objects given one hash that represents http
336             # headers to set and another hash that represents an object's metadata.
337 17         74 my $http_headers = HTTP::Headers->new;
338 17         187 while (my ($k, $v) = each %$headers) {
339 14         222 $http_headers->header($k => $v);
340             }
341 17         629 while (my ($k, $v) = each %$metadata) {
342 0         0 $http_headers->header("$METADATA_PREFIX$k" => $v);
343             }
344              
345 17 50       64 my $protocol = $self->secure ? 'https' : 'http';
346              
347 17         27 my $url;
348              
349 17 100       40 if ($self->{signature_version} == 4) {
350 16 100       41 if ($self->virtual_host) {
351 7         18 $url = $resource->to_virtual_hosted_style_url($protocol);
352             } else {
353 9         36 $url = $resource->to_path_style_url($protocol, $self->{region});
354             }
355             } else {
356 1         3 $url = $resource->to_url_without_region($protocol, $MAIN_HOST);
357             }
358              
359 17         76 my $request = HTTP::Request->new($method, $url, $http_headers, $content);
360             # sign the request using the signer, unless already signed
361 17 50       27891 if (!$request->header('Authorization')) {
362 17         1008 $self->{signer}->sign($request);
363             }
364 17         119 return $request;
365             }
366              
367             1;
368              
369             __END__