File Coverage

blib/lib/HTTP/CDN.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             package HTTP::CDN;
2             {
3             $HTTP::CDN::VERSION = '0.7';
4             }
5              
6 2     2   48012 use strict;
  2         6  
  2         73  
7 2     2   10 use warnings;
  2         3  
  2         65  
8              
9             # ABSTRACT: Serve static files with far-future expiry
10              
11             =head1 NAME
12              
13             HTTP::CDN
14              
15             =head1 DESCRIPTION
16              
17             Plugin for serving static files with far-future expiry.
18              
19             Additionally provides automatic minification/compiling of css/less/javascript
20              
21             =cut
22              
23 2     2   808 use Moose;
  0            
  0            
24             use Moose::Util::TypeConstraints;
25              
26             use URI;
27             use Path::Class;
28             use MIME::Types;
29             use Digest::MD5;
30             use Module::Load;
31              
32             our $mimetypes = MIME::Types->new;
33             our $default_mimetype = $mimetypes->type('application/octet-stream');
34              
35             use constant EXPIRES => 315_576_000; # ~ 10 years
36              
37             subtype 'HTTP::CDN::Dir' => as class_type('Path::Class::Dir');
38             subtype 'HTTP::CDN::URI' => as class_type('URI');
39              
40             coerce 'HTTP::CDN::Dir' => from 'Str' => via { Path::Class::dir($_)->resolve->absolute };
41             coerce 'HTTP::CDN::URI' => from 'Str' => via { URI->new($_) };
42              
43             has 'plugins' => (
44             traits => ['Array'],
45             isa => 'ArrayRef[Str]',
46             required => 1,
47             default => sub { [qw(HTTP::CDN::CSS)] },
48             handles => {
49             plugins => 'elements',
50             },
51             );
52             has 'base' => (
53             isa => 'HTTP::CDN::URI',
54             is => 'rw',
55             required => 1,
56             coerce => 1,
57             default => sub { URI->new('') },
58             );
59             has 'root' => (
60             isa => 'HTTP::CDN::Dir',
61             is => 'ro',
62             required => 1,
63             coerce => 1,
64             );
65             has '_cache' => (
66             isa => 'HashRef',
67             is => 'ro',
68             required => 1,
69             default => sub { {} },
70             );
71              
72             sub BUILD {
73             my ($self) = @_;
74              
75             my @plugins;
76              
77             foreach my $plugin ( $self->plugins ) {
78             eval { load "HTTP::CDN::$plugin" };
79             if ( $@ ) {
80             load $plugin;
81             }
82             else {
83             $plugin = "HTTP::CDN::$plugin";
84             }
85             push @plugins, $plugin;
86             }
87             $self->{plugins} = \@plugins;
88             }
89              
90             sub to_plack_app {
91             my ($self) = @_;
92              
93             load 'Plack::Request';
94             load 'Plack::Response';
95              
96             return sub {
97             my $request = Plack::Request->new(@_);
98             my $response = Plack::Response->new(200);
99              
100             my ($uri, $hash) = $self->unhash_uri($request->path);
101              
102             my $info = eval { $self->fileinfo($uri) };
103              
104             unless ( $info and $info->{hash} eq $hash ) {
105             $response->status(404);
106             $response->content_type( 'text/plain' );
107             $response->body( 'HTTP::CDN - not found' );
108             return $response->finalize;
109             }
110              
111             $response->status( 200 );
112             $response->content_type( $info->{mime}->type );
113             $response->headers->header('Last-Modified' => HTTP::Date::time2str($info->{stat}->mtime));
114             $response->headers->header('Expires' => HTTP::Date::time2str(time + EXPIRES));
115             $response->headers->header('Cache-Control' => 'max-age=' . EXPIRES . ', public');
116             $response->body($self->filedata($uri));
117             return $response->finalize;
118             }
119             }
120              
121             sub unhash_uri {
122             my ($self, $uri) = @_;
123              
124             unless ( $uri =~ s/\.([0-9A-F]{12})\.([^.]+)$/\.$2/ ) {
125             return;
126             }
127             my $hash = $1;
128             return wantarray ? ($uri, $hash) : $uri;
129             }
130              
131             sub cleanup_uri {
132             my ($self, $uri) = @_;
133              
134             return $self->root->file($uri)->cleanup->relative($self->root);
135             }
136              
137             sub resolve {
138             my ($self, $uri) = @_;
139              
140             my $fileinfo = $self->update($uri);
141              
142             return $self->base . $fileinfo->{components}{cdnfile};
143             }
144              
145             sub fileinfo {
146             my ($self, $uri) = @_;
147              
148             return $self->update($uri);
149             }
150              
151             sub filedata {
152             my ($self, $uri) = @_;
153              
154             return $self->_fileinfodata($self->update($uri));
155             }
156              
157             sub _fileinfodata {
158             my ($self, $fileinfo) = @_;
159              
160             return $fileinfo->{data} // scalar($fileinfo->{fullpath}->slurp);
161             }
162              
163             sub update {
164             my ($self, $uri) = @_;
165              
166             die "No URI specified" unless $uri;
167              
168             my $force_update;
169              
170             my $fragment = $1 if $uri =~ s/(#.*)//;
171              
172             my $file = $self->cleanup_uri($uri);
173              
174             my $fileinfo = $self->_cache->{$file} ||= {};
175              
176             unless ( $fragment ~~ $fileinfo->{components}{fragment} ) {
177             $fileinfo->{components}{fragment} = $fragment;
178             $force_update = 1;
179             }
180              
181             my $fullpath = $fileinfo->{fullpath} //= $self->root->file($file);
182              
183             my $stat = $fullpath->stat;
184              
185             die "Failed to stat $fullpath" unless $stat;
186              
187             unless ( not $force_update and $fileinfo->{stat} and $fileinfo->{stat}->mtime == $stat->mtime ) {
188             $fileinfo->{mime} = $mimetypes->mimeTypeOf($file) // $default_mimetype;
189             delete $fileinfo->{data};
190             $fileinfo->{dependancies} = {};
191              
192             $fileinfo->{components} = do {
193             my $extension = "$file";
194             $extension =~ s/(.*)\.//;
195             {
196             file => "$file",
197             extension => $extension,
198             barename => $1,
199             fragment => $fileinfo->{components}{fragment},
200             }
201             };
202              
203             foreach my $plugin ( $self->plugins ) {
204             next unless $plugin->can('preprocess');
205             $plugin->can('preprocess')->($self, $file, $stat, $fileinfo);
206             }
207              
208             # Need to update this file
209             $fileinfo->{hash} = $self->hash_fileinfo($fileinfo);
210             $fileinfo->{components}{cdnfile} = join('.', $fileinfo->{components}{barename}, $fileinfo->{hash}, $fileinfo->{components}{extension});
211             $fileinfo->{components}{cdnfile} .= $fileinfo->{components}{fragment} if $fileinfo->{components}{fragment};
212             }
213             # TODO - need to check dependancies?
214              
215             $fileinfo->{stat} = $stat;
216              
217             return $fileinfo;
218             }
219              
220             sub hash_fileinfo {
221             my ($self, $fileinfo) = @_;
222              
223             return uc substr(Digest::MD5::md5_hex(scalar($self->_fileinfodata($fileinfo))), 0, 12);
224             }
225              
226             1;