File Coverage

blib/lib/Furl/S3.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package Furl::S3;
2              
3 4     4   2718 use strict;
  4         8  
  4         147  
4 4     4   21 use warnings;
  4         5  
  4         117  
5 4     4   3695 use Class::Accessor::Lite;
  4         11428  
  4         30  
6 4     4   5887 use Furl::HTTP qw(HEADERS_AS_HASHREF);
  0            
  0            
7             use Digest::HMAC_SHA1;
8             use MIME::Base64 qw(encode_base64);
9             use HTTP::Date;
10             use Data::Dumper;
11             use XML::LibXML;
12             use XML::LibXML::XPathContext;
13             use Furl::S3::Error;
14             use Params::Validate qw(:types validate_with validate_pos);
15             use URI::Escape qw(uri_escape_utf8);
16             use Carp ();
17              
18             Class::Accessor::Lite->mk_accessors(qw(aws_access_key_id aws_secret_access_key secure furl endpoint));
19              
20             our $VERSION = '0.02';
21             our $DEFAULT_ENDPOINT = 's3.amazonaws.com';
22             our $XMLNS = 'http://s3.amazonaws.com/doc/2006-03-01/';
23              
24             sub new {
25             my $class = shift;
26             validate_with(
27             params => \@_,
28             spec => {
29             aws_access_key_id => 1,
30             aws_secret_access_key => 1,
31             },
32             allow_extra => 1,
33             );
34             my %args = @_;
35             my $aws_access_key_id = delete $args{aws_access_key_id};
36             my $aws_secret_access_key = delete $args{aws_secret_access_key};
37             Carp::croak("aws_access_key_id and aws_secret_access_key are mandatory") unless $aws_access_key_id && $aws_secret_access_key;
38             my $secure = delete $args{secure} || '0';
39             my $endpoint = delete $args{endpoint} || $DEFAULT_ENDPOINT;
40             my $furl = Furl::HTTP->new(
41             agent => '$class/'. $VERSION,
42             %args,
43             header_format => HEADERS_AS_HASHREF,
44             );
45             my $self = bless {
46             endpoint => $endpoint,
47             secure => $secure,
48             aws_access_key_id => $aws_access_key_id,
49             aws_secret_access_key => $aws_secret_access_key,
50             furl => $furl,
51             }, $class;
52             $self;
53             }
54              
55             sub _trim {
56             my $str = shift;
57             $str =~ s/^\s+//;
58             $str =~ s/\s+$//;
59             $str;
60             }
61              
62             sub _remove_quote {
63             my $str = shift;
64             $str =~ s/^"//;
65             $str =~ s/"$//;
66             $str;
67             }
68              
69             sub _boolean {
70             my $str = shift;
71             if ( $str eq 'false' ) {
72             return 0;
73             }
74             return 1;
75             }
76              
77             # http://docs.amazonwebservices.com/AmazonS3/2006-03-01/dev/index.html?BucketRestrictions.html
78             sub validate_bucket {
79             my $bucket = shift;
80             return
81             ($bucket =~ qr/^[a-z0-9][a-z0-9\._-]{2,254}$/) &&
82             ($bucket !~ /^\d+\.\d+\.\d+\.\d+$/); # IP Address
83             }
84              
85             sub is_dns_style {
86             my $bucket = shift;
87             return unless validate_bucket( $bucket );
88             return if $bucket =~ /_/;
89             return if length($bucket) < 3 || length($bucket) > 63;
90             return if $bucket =~ /\.\./;
91             my @parts = split /\./, $bucket;
92             for my $p(@parts) {
93             return if $p =~ /-$/
94             }
95             return 1;
96             }
97              
98             sub string_to_sign {
99             my( $self, $method, $resource, $headers ) = @_;
100             $headers ||= {};
101             my %headers_to_sign;
102             while (my($k, $v) = each %{$headers}) {
103             my $key = lc $k;
104             if ( $key =~ /^(content-md5|content-type|date|expires)$/ or
105             $key =~ /^x-amz-/ ) {
106             $headers_to_sign{$key} = _trim($v);
107             }
108             }
109             my $str = "$method\n";
110             $str .= $headers_to_sign{'content-md5'} || '';
111             $str .= "\n";
112             $str .= $headers_to_sign{'content-type'} || '';
113             $str .= "\n";
114             $str .= $headers_to_sign{'expires'} || $headers_to_sign{'date'} || '';
115             $str .= "\n";
116             for my $key( sort grep { /^x-amz-/ } keys %headers_to_sign ) {
117             $str .= "$key:$headers_to_sign{$key}\n";
118             }
119             my( $path, $query ) = split /\?/, $resource;
120             # sub-resource.
121             if ( $query && $query =~ m{^(acl|policy|location|versions)$} ) {
122             $str .= $resource;
123             }
124             else {
125             $str .= $path;
126             }
127              
128             $str;
129             }
130              
131             sub sign {
132             my( $self, $str ) = @_;
133             my $hmac = Digest::HMAC_SHA1->new( $self->aws_secret_access_key );
134             $hmac->add( $str );
135             encode_base64( $hmac->digest, '' );
136             }
137              
138             sub resource {
139             my( $self, $bucket, $key, $subresource ) = @_;
140             my $resource = $bucket;
141             $resource = '/'. $resource unless $resource =~ m{^/};
142             if ( defined $key ) {
143             $key = _normalize_key($key);
144             $resource = join '/', $resource, $key;
145             }
146             if ( $subresource ) {
147             $resource .= '?'. $subresource;
148             }
149             $resource =~ s{//}{/}g;
150             $resource;
151             }
152              
153             sub _path_query {
154             my( $self, $path, $q ) = @_;
155             $path = '/'. $path unless $path =~ m{^/};
156             my $qs = ref($q) eq 'HASH' ?
157             join('&', map { $_. '='. uri_escape_utf8( $q->{$_} ) } keys %{$q}) : $q;
158             $path .= '?'. $qs if $qs;
159             $path;
160             }
161              
162             sub host_and_path_query {
163             my( $self, $bucket, $key, $params ) = @_;
164             my($host, $path_query);
165             $key = _normalize_key($key);
166             if ( is_dns_style($bucket) ) {
167             $host = join '.', $bucket, $self->endpoint;
168             $path_query = $self->_path_query( $key, $params );
169             }
170             else {
171             $host = $self->endpoint;
172             $path_query = $self->_path_query( join('/', $bucket, $key), $params );
173             }
174             $path_query =~ s{//}{/}g;
175             return ($host, $path_query);
176             }
177              
178             sub request {
179             my $self = shift;
180             my( $method, $bucket, $key, $params, $headers, $furl_options ) = @_;
181             validate_pos( @_, 1, 1,
182             { type => SCALAR | UNDEF, optional => 1 },
183             { type => HASHREF | UNDEF | SCALAR , optional => 1, },
184             { type => HASHREF | UNDEF , optional => 1, },
185             { type => HASHREF | UNDEF , optional => 1, }, );
186             $self->clear_error;
187             $key ||= '';
188             $params ||= +{};
189             $headers ||= +{};
190             $furl_options ||= +{};
191              
192             my %h;
193             while (my($key, $val) = each %{$headers}) {
194             $key =~ s/_/-/g; # content_type => content-type
195             $h{lc($key)} = $val
196             }
197             if ( !$h{'expires'} && !$h{'date'} ) {
198             $h{'date'} = time2str(time);
199             }
200             my $resource = $self->resource( $bucket, $key );
201             my $string_to_sign =
202             $self->string_to_sign( $method, $resource, \%h );
203             my $signed_string = $self->sign( $string_to_sign );
204             my $auth_header = 'AWS '. $self->aws_access_key_id. ':'. $signed_string;
205             $h{'authorization'} = $auth_header;
206              
207             my( $host, $path_query ) =
208             $self->host_and_path_query( $bucket, $key, $params );
209             my %res;
210             my @h = %h;
211             @res{qw(ver code msg headers body)} = $self->furl->request(
212             method => $method,
213             scheme => ($self->secure ? 'https' : 'http'),
214             host => $host,
215             path_query => $path_query,
216             headers => \@h,
217             %{$furl_options},
218             );
219             return \%res;
220             }
221              
222             sub signed_url {
223             my $self = shift;
224             validate_pos(@_, 1, 1, +{ regexp => qr/^\d+$/, });
225             my( $bucket, $key, $expires ) = @_;
226             my $resource = $self->resource( $bucket, $key );
227             my $string_to_sign = $self->string_to_sign('GET', $resource, +{
228             expires => $expires,
229             });
230             my $sig = $self->sign( $string_to_sign );
231             my($host, $path_query) = $self->host_and_path_query( $bucket, $key, +{
232             AWSAccessKeyId => $self->aws_access_key_id,
233             Expires => $expires,
234             Signature => $sig,
235             } );
236             sprintf '%s://%s%s', ($self->secure ? 'https' : 'http'), $host, $path_query;
237             }
238              
239              
240             sub _create_xpc {
241             my( $self, $string ) = @_;
242             my $xml = XML::LibXML->new;
243             my $doc = $xml->parse_string( $string );
244             my $xpc = XML::LibXML::XPathContext->new( $doc );
245             $xpc->registerNs('s3' => $XMLNS);
246             return $xpc;
247             }
248              
249             sub list_buckets {
250             my $self = shift;
251             my $res = $self->request( 'GET', '/' );
252             unless ( _http_is_success($res->{code}) ) {
253             return $self->error( $res );
254             }
255             my $xpc = $self->_create_xpc( $res->{body} );
256             my @buckets;
257             for my $node($xpc->findnodes('/s3:ListAllMyBucketsResult/s3:Buckets/s3:Bucket')) {
258             my $name = $xpc->findvalue('./s3:Name', $node);
259             my $creation_date = $xpc->findvalue('./s3:CreationDate', $node);
260             push @buckets, +{
261             name => $name,
262             creation_date => $creation_date,
263             };
264             }
265             return +{
266             buckets => \@buckets,
267             owner => +{
268             id => $xpc->findvalue('/s3:ListAllMyBucketsResult/s3:Owner/s3:ID'),
269             display_name => $xpc->findvalue('/s3:ListAllMyBucketsResult/s3:Owner/s3:DisplayName'),
270             },
271             }
272             }
273              
274             sub create_bucket {
275             my $self = shift;
276             my( $bucket, $headers ) = @_;
277             validate_pos( @_,
278             { type => SCALAR,
279             callbacks => { bucket_name => \&validate_bucket } },
280             { type => HASHREF, optional => 1, } );
281              
282             my $res = $self->request( 'PUT', $bucket, undef, undef, $headers );
283             unless ( _http_is_success($res->{code}) ) {
284             return $self->error( $res );
285             }
286             return 1;
287             }
288              
289             sub delete_bucket {
290             my $self = shift;
291             my( $bucket ) = @_;
292             validate_pos( @_, 1 );
293             my $res = $self->request( 'DELETE', $bucket );
294             unless ( _http_is_success($res->{code}) ) {
295             return $self->error( $res );
296             }
297             return 1;
298             }
299              
300             sub list_objects {
301             my $self = shift;
302             my( $bucket, $params ) = @_;
303             validate_pos( @_, 1, { type => HASHREF, optional => 1 });
304             my $res = $self->request( 'GET', $bucket, undef, $params );
305             unless ( _http_is_success($res->{code}) ) {
306             return $self->error( $res );
307             }
308             my $xpc = $self->_create_xpc( $res->{body} );
309             my @contents;
310             for my $node($xpc->findnodes('/s3:ListBucketResult/s3:Contents')) {
311             push @contents, +{
312             key => $xpc->findvalue('./s3:Key', $node),
313             etag => _remove_quote( $xpc->findvalue('./s3:ETag', $node) ),
314             storage_class => $xpc->findvalue('./s3:StorageClass', $node),
315             last_modified => $xpc->findvalue('./s3:LastModified', $node),
316             size => $xpc->findvalue('./s3:Size', $node),
317             owner => +{
318             id => $xpc->findvalue('./s3:Owner/s3:ID', $node),
319             display_name => $xpc->findvalue('./s3:Owner/s3:DisplayName', $node),
320             },
321             };
322             }
323             my @common_prefixes;
324             for my $node($xpc->findnodes('/s3:ListBucketResult/s3:CommonPrefixes')) {
325             push @common_prefixes, +{
326             prefix => $xpc->findvalue('./s3:Prefix', $node),
327             };
328             }
329             return +{
330             name => $xpc->findvalue('/s3:ListBucketResult/s3:Name'),
331             is_truncated => _boolean($xpc->findvalue('/s3:ListBucketResult/s3:IsTruncated')),
332             delimiter => $xpc->findvalue('/s3:ListBucketResult/s3:Delimiter'),
333             max_keys => $xpc->findvalue('/s3:ListBucketResult/s3:MaxKeys'),
334             marker => $xpc->findvalue('/s3:ListBucketResult/s3:Marker'),
335             contents => \@contents,
336             common_prefixes => \@common_prefixes,
337             };
338             }
339              
340             sub create_object {
341             my $self = shift;
342             my( $bucket, $key, $content, $headers ) = @_;
343             validate_pos( @_, 1, 1,
344             { type => HANDLE | SCALAR },
345             { type => HASHREF, optional => 1 } );
346             my $res = $self->request( 'PUT', $bucket, $key, undef, $headers, +{ content => $content });
347             unless ( _http_is_success($res->{code}) ) {
348             return $self->error( $res );
349             }
350             return 1;
351             }
352              
353             sub create_object_from_file {
354             my $self = shift;
355             my( $bucket, $key, $filename, $headers ) = @_;
356             validate_pos( @_, 1, 1, 1,
357             { type => HASHREF, optional => 1 } );
358              
359             $headers ||= {};
360             my $has_ct = 0;
361             for my $key( keys %{$headers} ) {
362             if (lc($key) =~ qr/^(content_type|content-type)$/) {
363             $has_ct = 1;
364             last ;
365             }
366             }
367             unless ( $has_ct ) {
368             require File::Type;
369             my $ft = File::Type->new;
370             my $content_type = $ft->checktype_filename( $filename );
371             $headers->{'content_type'} = $content_type;
372             }
373             open my $fh, '<', $filename or die "$!: $filename";
374             $self->create_object( $bucket, $key, $fh, $headers )
375             }
376              
377             sub copy_object {
378             my $self = shift;
379             my( $source_bucket, $source_key, $dest_bucket, $dest_key, $headers ) = @_;
380             validate_pos( @_,
381             1, 1, 1,
382             { type => SCALAR | UNDEF, optional => 1 },
383             { type => HASHREF, optional => 1} );
384             $headers ||= +{};
385             my $source = $self->resource( $source_bucket, $source_key );
386             $self->create_object( $dest_bucket, $dest_key, '', {
387             %{$headers},
388             'x-amz-copy-source' => $source,
389             });
390             }
391              
392             sub _normalize_response {
393             my( $self, $res, $is_head ) = @_;
394             my %res;
395             while (my($k, $v) = each %{$res->{headers}}) {
396             $res{$k} = $v;
397             }
398             # remove etag's double quote.
399             if ( my $etag = $res{'etag'} ) {
400             $res{etag} = _remove_quote( $etag );
401             }
402             # make aliases
403             $res{content_length} = $res{'content-length'};
404             $res{content_type} = $res{'content-type'};
405             $res{last_modified} = $res{'last-modified'};
406             unless ( $is_head ) {
407             $res{content} = $res->{body};
408             }
409             return \%res;
410             }
411              
412              
413             sub get_object {
414             my $self = shift;
415             my( $bucket, $key, $headers, $furl_options ) = @_;
416             validate_pos( @_, 1, 1,
417             { type => HASHREF, optional => 1 },
418             { type => HASHREF, optional => 1 }, );
419             my $res = $self->request( 'GET', $bucket, $key, undef, $headers, $furl_options );
420             unless ( _http_is_success($res->{code}) ) {
421             return $self->error( $res );
422             }
423             $self->_normalize_response( $res );
424             }
425              
426             sub get_object_to_file {
427             my $self = shift;
428             my( $bucket, $key, $filename ) = @_;
429             validate_pos( @_, 1, 1, 1 );
430             open my $fh, '>', $filename or die "$!: $filename";
431             $self->get_object( $bucket, $key, {}, {
432             write_file => $fh,
433             });
434             }
435              
436             sub head_object {
437             my $self = shift;
438             my( $bucket, $key, $headers ) = @_;
439             validate_pos( @_, 1, 1, { type => HASHREF, optional => 1 } );
440             my $res = $self->request( 'HEAD', $bucket, $key, undef, $headers );
441             unless ( _http_is_success($res->{code}) ) {
442             return $self->error( $res );
443             }
444             $self->_normalize_response( $res, 1 );
445             }
446              
447             sub delete_object {
448             my $self = shift;
449             my( $bucket, $key ) = @_;
450             validate_pos( @_, 1, 1 );
451             my $res = $self->request( 'DELETE', $bucket, $key );
452             unless ( _http_is_success($res->{code}) ) {
453             return $self->error( $res );
454             }
455             return 1;
456             }
457              
458             sub clear_error {
459             my $self = shift;
460             delete $self->{_error};
461             }
462              
463             sub error {
464             my $self = shift;
465             if ( @_ ) {
466             my $error = Furl::S3::Error->new( $_[0] );
467             $self->{_error} = $error;
468             return ;
469             }
470             $self->{_error};
471             }
472              
473             sub _normalize_key {
474             my $key = shift;
475             join '/', map { _uri_escape($_) } split /\//, $key;
476             }
477              
478             sub _http_is_success {
479             $_[0] >= 200 && $_[0] < 300;
480             }
481              
482             sub _uri_escape {
483             uri_escape_utf8($_[0], '^A-Za-z0-9\._-');
484             }
485             1;
486              
487              
488             __END__