File Coverage

blib/lib/Amazon/S3/Thin.pm
Criterion Covered Total %
statement 158 187 84.4
branch 59 82 71.9
condition 18 32 56.2
subroutine 28 32 87.5
pod 15 15 100.0
total 278 348 79.8


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