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   100425 use 5.008001;
  8         86  
3 8     8   42 use strict;
  8         29  
  8         186  
4 8     8   42 use warnings;
  8         14  
  8         304  
5 8     8   50 use Carp;
  8         16  
  8         746  
6 8     8   5847 use LWP::UserAgent;
  8         393328  
  8         323  
7 8     8   80 use Digest::MD5;
  8         17  
  8         335  
8 8     8   5288 use Encode;
  8         118566  
  8         714  
9 8     8   4073 use Amazon::S3::Thin::Resource;
  8         21  
  8         257  
10 8     8   3794 use Amazon::S3::Thin::Credentials;
  8         26  
  8         18993  
11              
12             our $VERSION = '0.30';
13              
14             my $METADATA_PREFIX = 'x-amz-meta-';
15             my $MAIN_HOST = 's3.amazonaws.com';
16              
17             sub new {
18 18     18 1 12975 my $class = shift;
19 18         38 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     126 if ($self->{credential_provider} and $self->{credential_provider} eq 'env') {
    50 33        
24 1         9 $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       263 croak "No aws_access_key_id" unless $self->{aws_access_key_id};
32 16 100       142 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         120 );
40 15         45 delete $self->{aws_access_key_id};
41 15         29 delete $self->{aws_secret_access_key};
42 15         31 delete $self->{aws_session_token};
43             }
44 16         29 delete $self->{credential_provider};
45              
46 16         31 bless $self, $class;
47              
48 16 100       52 $self->secure(0) unless defined $self->secure;
49 16 100       53 $self->ua($self->_default_ua) unless defined $self->ua;
50 16 100       49 $self->debug(0) unless defined $self->debug;
51 16 100       47 $self->virtual_host(0) unless defined $self->virtual_host;
52              
53 16 100       51 $self->{signature_version} = 4 unless defined $self->{signature_version};
54 16 50 66     93 if ($self->{signature_version} == 4 && ! $self->{region}) {
55 0         0 croak "Please set region when you use signature v4";
56             }
57              
58 16         50 $self->{signer} = $self->_load_signer($self->{signature_version});
59 16         138 return $self;
60             }
61              
62             sub _load_signer {
63 16     16   28 my $self = shift;
64 16         22 my $version = shift;
65 16         47 my $signer_class = "Amazon::S3::Thin::Signer::V$version";
66 16 50       1021 eval "require $signer_class" or die $@;
67              
68 16 100       115 if ($version == 2) {
    50          
69 3         28 return $signer_class->new($self->{credentials}, $MAIN_HOST);
70             } elsif ($version == 4) {
71 13         99 return $signer_class->new($self->{credentials}, $self->{region});
72             }
73             }
74              
75              
76             sub _default_ua {
77 8     8   13 my $self = shift;
78              
79 8         53 my $ua = LWP::UserAgent->new(
80             keep_alive => 10,
81             requests_redirectable => [qw(GET HEAD DELETE PUT)],
82             );
83 8         11736 $ua->timeout(30);
84 8         126 $ua->env_proxy;
85 8         8997 return $ua;
86             }
87              
88             # Accessors
89              
90             sub secure {
91 56     56 1 105 my $self = shift;
92 56 100       143 if (@_) {
93 15         39 $self->{secure} = shift;
94             } else {
95 41         178 return $self->{secure};
96             }
97             }
98              
99             sub debug {
100 35     35 1 57 my $self = shift;
101 35 100       78 if (@_) {
102 16         38 $self->{debug} = shift;
103             } else {
104 19         99 return $self->{debug};
105             }
106             }
107              
108             sub ua {
109 43     43 1 79 my $self = shift;
110 43 100       105 if (@_) {
111 8         16 $self->{ua} = shift;
112             } else {
113 35         147 return $self->{ua};
114             }
115             }
116              
117             sub virtual_host {
118 53     53 1 84 my $self = shift;
119 53 100       111 if (@_) {
120 15         34 $self->{virtual_host} = shift;
121             } else {
122 38         129 return $self->{virtual_host};
123             }
124             }
125              
126             sub _send {
127 17     17   40 my ($self, $request) = @_;
128 17 50       54 warn "[Request]\n" , $request->as_string if $self->{debug};
129 17         53 my $response = $self->ua->request($request);
130 17 50       228 warn "[Response]\n" , $response->as_string if $self->{debug};
131 17         52 return $response;
132             }
133              
134             # API calls
135              
136             sub get_object {
137 5     5 1 4630 my ($self, $bucket, $key, $headers) = @_;
138 5         18 my $request = $self->_compose_request('GET', $self->_resource($bucket, $key), $headers);
139 5         29 return $self->_send($request);
140             }
141              
142             sub head_object {
143 2     2 1 16 my ($self, $bucket, $key) = @_;
144 2         7 my $request = $self->_compose_request('HEAD', $self->_resource($bucket, $key));
145 2         10 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 7150 my ($self, $src_bucket, $src_key, $dst_bucket, $dst_key, $headers) = @_;
156 4   100     24 $headers ||= {};
157 4         19 $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         26 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       17 if ($self->_looks_like_special_case_error($res)) {
165 2         56 $res->code(500);
166             }
167 4         82 return $res;
168             }
169              
170             sub _looks_like_special_case_error {
171 4     4   13 my ($self, $res) = @_;
172 4   33     13 return $res->code == 200 && (length $res->content == 0 || $res->content =~ //);
173             }
174              
175             sub put_object {
176 2     2 1 26 my ($self, $bucket, $key, $content, $headers) = @_;
177 2 50 33     15 croak 'must specify key' unless $key && length $key;
178              
179 2 50       10 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       12 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     13 $headers->{'Content-Length'} ||= length $content;
191             }
192              
193 2 50       27 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         30 my $request = $self->_compose_request('PUT', $self->_resource($bucket, $key), $headers, $content);
204 2         18 return $self->_send($request);
205             }
206             }
207              
208             sub list_objects {
209 2     2 1 11626 my ($self, $bucket, $opt) = @_;
210 2 50       11 croak 'must specify bucket' unless $bucket;
211 2   50     9 $opt ||= {};
212              
213 2         3 my $query_string;
214 2 50       7 if (%$opt) {
215             $query_string = join('&',
216 2         13 map { $_ . "=" . Amazon::S3::Thin::Resource->urlencode($opt->{$_}) } sort keys %$opt);
  4         491  
217             }
218              
219 2         85 my $resource = $self->_resource($bucket, undef, $query_string);
220 2         11 my $request = $self->_compose_request('GET', $resource);
221 2         10 my $response = $self->_send($request);
222 2         12 return $response;
223             }
224              
225             sub delete_multiple_objects {
226 2     2 1 3168 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         31 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         15 my $response = $self->_send($request);
241 2         10 return $response;
242             }
243              
244             sub _build_xml_for_delete {
245 2     2   8 my (@keys) = @_;
246              
247 2         17 my $content = 'true';
248              
249 2         8 foreach my $k (@keys) {
250 4         487 $content .= ''
251             . Encode::encode('UTF-8', $k)
252             . '';
253             }
254 2         66 $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 9479 my ($self, $bucket, $key, $fields, $conditions, $expires_in) = @_;
288              
289 9 100       104 croak 'must specify bucket' unless defined $bucket;
290 8 100       107 croak 'must specify key' unless defined $key;
291              
292 7 100       19 if ($self->{signature_version} == 4) {
293 6         17 my $resource = $self->_resource($bucket);
294 6 50       18 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         213 croak 'generate_presigned_post is only supported on signature v4';
306             }
307             }
308              
309             sub _resource {
310 23     23   82 my ($self, $bucket, $key, $query_string) = @_;
311 23         162 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   48 my ($self, $method, $resource, $headers, $content, $metadata) = @_;
327 17 50       44 croak 'must specify method' unless $method;
328 17 50       43 croak 'must specify resource' unless defined $resource;
329 17 50       55 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     60 $headers ||= {};
333 17   50     78 $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         81 my $http_headers = HTTP::Headers->new;
338 17         192 while (my ($k, $v) = each %$headers) {
339 14         259 $http_headers->header($k => $v);
340             }
341 17         668 while (my ($k, $v) = each %$metadata) {
342 0         0 $http_headers->header("$METADATA_PREFIX$k" => $v);
343             }
344              
345 17 50       65 my $protocol = $self->secure ? 'https' : 'http';
346              
347 17         33 my $url;
348              
349 17 100       46 if ($self->{signature_version} == 4) {
350 16 100       40 if ($self->virtual_host) {
351 7         24 $url = $resource->to_virtual_hosted_style_url($protocol);
352             } else {
353 9         32 $url = $resource->to_path_style_url($protocol, $self->{region});
354             }
355             } else {
356 1         7 $url = $resource->to_url_without_region($protocol, $MAIN_HOST);
357             }
358              
359 17         87 my $request = HTTP::Request->new($method, $url, $http_headers, $content);
360             # sign the request using the signer, unless already signed
361 17 50       33478 if (!$request->header('Authorization')) {
362 17         992 $self->{signer}->sign($request);
363             }
364 17         143 return $request;
365             }
366              
367             1;
368              
369             __END__