File Coverage

blib/lib/Amazon/CloudFront/Thin.pm
Criterion Covered Total %
statement 91 93 97.8
branch 10 14 71.4
condition 5 11 45.4
subroutine 21 21 100.0
pod 3 3 100.0
total 130 142 91.5


line stmt bran cond sub pod time code
1             package Amazon::CloudFront::Thin;
2 5     5   127173 use strict;
  5         12  
  5         128  
3 5     5   26 use warnings;
  5         8  
  5         120  
4 5     5   81314 use URI ();
  5         37178  
  5         97  
5 5     5   31 use URI::Escape ();
  5         7  
  5         78  
6 5     5   23 use Carp ();
  5         1045  
  5         72  
7 5     5   52314 use HTTP::Headers ();
  5         34432  
  5         130  
8 5     5   69549 use HTTP::Date ();
  5         23084  
  5         130  
9 5     5   3576 use HTTP::Request ();
  5         41521  
  5         108  
10 5     5   3455 use Digest::SHA ();
  5         14319  
  5         5664  
11              
12             our $VERSION = '0.04';
13              
14             sub new {
15 13     13 1 11682 my ($class, @extra) = @_;
16 13         22 my $args;
17 13         25 my $self = {};
18              
19 13 100       41 if (@extra == 1) {
20 5 50       23 Carp::croak 'please provide a hash or hash reference to new()'
21             unless ref $extra[0] eq 'HASH';
22 5         12 $args = $extra[0];
23             }
24             else {
25 8 50       29 Carp::croak 'please provide a hash or hash reference to new()'
26             unless @extra % 2 == 0;
27 8         30 $args = {@extra};
28             }
29              
30 13         29 foreach my $key (qw(aws_access_key_id aws_secret_access_key distribution_id)) {
31 29 100       79 if (exists $args->{$key}) {
32 21         60 $self->{$key} = $args->{$key};
33             }
34             else {
35 8         1291 Carp::croak "argument '$key' missing on call to new()";
36             }
37             }
38 5         13 bless $self, $class;
39 5   66     33 my $ua = $args->{ua} || _default_ua();
40 5         27 $self->ua($ua);
41              
42 5         40 return $self;
43             }
44              
45             sub _default_ua {
46 3     3   315477 require LWP::UserAgent;
47 3         171877 my $ua = LWP::UserAgent->new(
48             keep_alive => 10,
49             # requests_redirectable => [qw(GET HEAD DELETE PUT)]
50             );
51 3         193816 $ua->timeout(10);
52 3         65 $ua->env_proxy;
53 3         102207 return $ua;
54             }
55              
56             sub ua {
57 10     10 1 32 my ($self, $ua) = @_;
58 10 100       62 $self->{_ua} = $ua if ($ua);
59 10         74 return $self->{_ua};
60             }
61              
62             sub create_invalidation {
63 1     1 1 7 my ($self, @paths) = @_;
64 1 50 33     12 if (@paths == 1 && ref $paths[0] && ref $paths[0] eq 'ARRAY') {
      33        
65 0         0 @paths = @{$paths[0]};
  0         0  
66             }
67              
68 1         2 my $time = time;
69              
70             my $url = URI->new(
71             'https://cloudfront.amazonaws.com/2015-04-17/distribution/'
72 1         11 . $self->{distribution_id} . '/invalidation'
73             );
74              
75 1         9572 my $content = _create_xml_payload(\@paths, $time);
76              
77             # Amazon unfortunately does not comply with RFC 1123 for the
78             # 'date' header, requiring instead that it gets written in
79             # ISO-8601 format. Since HTTP::Headers does the right thing
80             # for date(), we set the ISO-8601 date in "X-Amz-Date" instead.
81 1         5 my ($formatted_date, $formatted_time) = _format_date($time);
82 1         9 my $http_headers = HTTP::Headers->new(
83             'Content-Length' => length $content,
84             'Content-Type' => 'text/xml',
85             'Host' => $url->host,
86             'X-Amz-Date' => $formatted_date . 'T' . $formatted_time . 'Z',
87             );
88              
89             $http_headers->header(
90             Authorization => 'AWS4-HMAC-SHA256 Credential='
91             . $self->{aws_access_key_id} . '/' . _cloudfront_scope($formatted_date)
92             . ', SignedHeaders=' . _signed_headers($http_headers)
93             . ', Signature='
94             . _calculate_signature(
95             $self->{aws_secret_access_key},
96 1         308 $url,
97             $http_headers,
98             $content
99             )
100             );
101              
102 1         54 my $request = HTTP::Request->new('POST', $url, $http_headers, $content);
103 1         261 return $self->ua->request($request);
104             }
105              
106             sub _cloudfront_scope {
107 3     3   7 my ($date) = @_;
108 3         32 return sprintf("%s/us-east-1/cloudfront/aws4_request", $date);
109             }
110              
111             sub _format_date {
112 6     6   1314 my ($time) = @_;
113 6         34 my @date = gmtime $time;
114 6         16 $date[5] += 1900; # fix the year
115 6         10 $date[4] += 1; # fix the month
116              
117             return (
118 6         42 sprintf('%d%02d%02d', @date[5,4,3]), # YYYYMMDD
119             sprintf('%02d%02d%02d', @date[2,1,0]) # hhmmss
120             );
121             }
122              
123             sub _calculate_signature {
124 1     1   47 my ($aws_secret_access_key, $url, $headers, $content) = @_;
125              
126 1         4 my $canonical_request = _create_canonical_request($url, $headers, $content);
127 1         68 my $string_to_sign = _create_string_to_sign($headers, $canonical_request);
128              
129 1         8 my ($date) = _format_date(
130             HTTP::Date::str2time($headers->header('X-Amz-Date'))
131             );
132 1         5 return _create_signature($aws_secret_access_key, $string_to_sign, $date);
133             }
134              
135             sub _create_canonical_request {
136 2     2   330 my ($url, $headers, $content) = @_;
137              
138             # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
139 2         13 my @sorted_header_names = sort $headers->header_field_names();
140             return
141             "POST\n"
142             . ($url->path || '/') . "\n\n"
143             . join( "\n", map {
144 2   50     83 lc($_) . ':' . $headers->header($_)
  8         209  
145             } @sorted_header_names
146             ) . "\n\n"
147             . _signed_headers($headers) . "\n"
148             . Digest::SHA::sha256_hex($content)
149             ;
150             }
151              
152             sub _signed_headers {
153 3     3   63 my ($headers) = @_;
154 3         13 return join(';' => map lc, sort $headers->header_field_names());
155             }
156              
157             sub _create_string_to_sign {
158 2     2   1107 my ($headers, $canonical_request) = @_;
159              
160 2         11 my ($formatted_date, $formatted_time) = _format_date(
161             HTTP::Date::str2time($headers->header('X-Amz-Date'))
162             );
163              
164             return
165 2         12 "AWS4-HMAC-SHA256\n"
166             . $formatted_date . 'T' . $formatted_time . "Z\n"
167             . _cloudfront_scope($formatted_date) . "\n"
168             . Digest::SHA::sha256_hex($canonical_request)
169             ;
170             }
171              
172             sub _create_signature {
173 2     2   478 my ($aws_secret_access_key, $string_to_sign, $date) = @_;
174              
175 2         92 return Digest::SHA::hmac_sha256_hex(
176             $string_to_sign, Digest::SHA::hmac_sha256(
177             'aws4_request', Digest::SHA::hmac_sha256(
178             'cloudfront', Digest::SHA::hmac_sha256(
179             'us-east-1', Digest::SHA::hmac_sha256($date, 'AWS4' . $aws_secret_access_key)
180             )
181             )
182             )
183             );
184             }
185              
186             sub _create_xml_payload {
187 2     2   544060 my ($paths, $identifier) = @_;
188 2         5 my $total_paths = scalar @$paths;
189 2         5 my $path_content;
190 2         7 foreach my $path (@$paths) {
191             # leading '/' is required:
192             # http://docs.aws.amazon.com/AmazonCloudFront/latest/APIReference/InvalidationBatchDatatype.html
193 3 50       17 $path = '/' . $path unless index($path, '/') == 0;
194             # we wrap paths on CDATA so we don't have to escape them
195 3         12 $path_content .= ''
196             }
197 2         14 return qq{$total_paths$path_content$identifier};
198             }
199              
200             42;
201             __END__