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.8';
4             }
5              
6 3     3   48740 use strict;
  3         7  
  3         169  
7 3     3   12 use warnings;
  3         3  
  3         106  
8              
9             =head1 NAME
10              
11             HTTP::CDN - Serve static files with unique URLs and far-future expiry
12              
13              
14             =head1 SYNOPSIS
15              
16             To use this module in your Catalyst app, see L<Catalyst::Plugin::CDN>. For
17             other uses, see below:
18              
19             my $cdn = HTTP::CDN->new(
20             root => '/path/to/static/root',
21             base => '/cdn/',
22             plugins => [qw(
23             CSS::LESSp
24             CSS
25             CSS::Minifier::XS
26             JavaScript::Minifier::XS
27             )],
28             );
29              
30             # Generate a URL based on hashed file contents
31              
32             say $cdn->resolve('css/style.less'); # e.g.: "/cdn/css/style.B97EA317759D.less"
33              
34             # Find source file, apply plugins and return content
35              
36             my ($uri, $hash) = $cdn->unhash_uri('css/style.B97EA317759D.less');
37             return $cdn->filedata($uri);
38              
39             In a real application you'd also want to add a Content-Type header using the
40             MIME type set by the plugins as well as headers for cache-control and expiry.
41             You can trivially mount a handler to do all of that for the static content in
42             your Plack app (using the HTTP::CDN object as defined above):
43              
44             use Plack::Builder;
45              
46             my $app = sub {
47             # Define Plack app here
48             };
49              
50             builder {
51             mount '/cdn/' => $cdn->to_plack_app;
52             mount '/' => $app;
53             }
54              
55              
56             =head1 DESCRIPTION
57              
58             Web application plugin for serving static files with content-hashed unique URLs
59             and far-future expiry.
60              
61             Additionally provides automatic minification/compiling of css/less/javascript.
62              
63             =cut
64              
65 3     3   1249 use Moose;
  0            
  0            
66             use Moose::Util::TypeConstraints;
67              
68             use URI;
69             use Path::Class;
70             use MIME::Types;
71             use Digest::MD5;
72             use Module::Load;
73              
74             our $mimetypes = MIME::Types->new;
75             our $default_mimetype = $mimetypes->type('application/octet-stream');
76              
77             use constant EXPIRES => 315_576_000; # ~ 10 years
78              
79             subtype 'HTTP::CDN::Dir' => as class_type('Path::Class::Dir');
80             subtype 'HTTP::CDN::URI' => as class_type('URI');
81              
82             coerce 'HTTP::CDN::Dir' => from 'Str' => via { Path::Class::dir($_)->resolve->absolute };
83             coerce 'HTTP::CDN::URI' => from 'Str' => via { URI->new($_) };
84              
85             has 'plugins' => (
86             traits => ['Array'],
87             isa => 'ArrayRef[Str]',
88             required => 1,
89             default => sub { [qw(HTTP::CDN::CSS)] },
90             handles => {
91             plugins => 'elements',
92             },
93             );
94             has 'base' => (
95             isa => 'HTTP::CDN::URI',
96             is => 'rw',
97             required => 1,
98             coerce => 1,
99             default => sub { URI->new('') },
100             );
101             has 'root' => (
102             isa => 'HTTP::CDN::Dir',
103             is => 'ro',
104             required => 1,
105             coerce => 1,
106             );
107             has '_cache' => (
108             isa => 'HashRef',
109             is => 'ro',
110             required => 1,
111             default => sub { {} },
112             );
113              
114             sub BUILD {
115             my ($self) = @_;
116              
117             my @plugins;
118              
119             foreach my $plugin ( $self->plugins ) {
120             eval { load "HTTP::CDN::$plugin" };
121             if ( $@ ) {
122             load $plugin;
123             }
124             else {
125             $plugin = "HTTP::CDN::$plugin";
126             }
127             push @plugins, $plugin;
128             }
129             $self->{plugins} = \@plugins;
130             }
131              
132             sub to_plack_app {
133             my ($self) = @_;
134              
135             load 'Plack::Request';
136             load 'Plack::Response';
137              
138             return sub {
139             my $request = Plack::Request->new(@_);
140             my $response = Plack::Response->new(200);
141              
142             my ($uri, $hash) = $self->unhash_uri($request->path);
143              
144             my $info = eval { $self->fileinfo($uri) };
145              
146             unless ( $info and $info->{hash} eq $hash ) {
147             $response->status(404);
148             $response->content_type( 'text/plain' );
149             $response->body( 'HTTP::CDN - not found' );
150             return $response->finalize;
151             }
152              
153             $response->status( 200 );
154             $response->content_type( $info->{mime}->type );
155             $response->headers->header('Last-Modified' => HTTP::Date::time2str($info->{stat}->mtime));
156             $response->headers->header('Expires' => HTTP::Date::time2str(time + EXPIRES));
157             $response->headers->header('Cache-Control' => 'max-age=' . EXPIRES . ', public');
158             $response->body($self->filedata($uri));
159             return $response->finalize;
160             }
161             }
162              
163             sub unhash_uri {
164             my ($self, $uri) = @_;
165              
166             unless ( $uri =~ s/\.([0-9A-F]{12})\.([^.]+)$/\.$2/ ) {
167             return;
168             }
169             my $hash = $1;
170             return wantarray ? ($uri, $hash) : $uri;
171             }
172              
173             sub cleanup_uri {
174             my ($self, $uri) = @_;
175              
176             return $self->root->file($uri)->cleanup->relative($self->root);
177             }
178              
179             sub resolve {
180             my ($self, $uri) = @_;
181              
182             my $fileinfo = $self->update($uri);
183              
184             return $self->base . $fileinfo->{components}{cdnfile};
185             }
186              
187             sub fileinfo {
188             my ($self, $uri) = @_;
189              
190             return $self->update($uri);
191             }
192              
193             sub filedata {
194             my ($self, $uri) = @_;
195              
196             return $self->_fileinfodata($self->update($uri));
197             }
198              
199             sub _fileinfodata {
200             my ($self, $fileinfo) = @_;
201              
202             return $fileinfo->{data} // scalar($fileinfo->{fullpath}->slurp);
203             }
204              
205             sub update {
206             my ($self, $uri) = @_;
207              
208             die "No URI specified" unless $uri;
209              
210             my $force_update;
211              
212             my $fragment = ($uri =~ s/(#.*)//) ? $1 : undef;
213              
214             my $file = $self->cleanup_uri($uri);
215              
216             my $fileinfo = $self->_cache->{$file} ||= {};
217              
218             if ( ($fragment // '') ne ($fileinfo->{components}{fragment} // '') ) {
219             $fileinfo->{components}{fragment} = $fragment;
220             $force_update = 1;
221             }
222              
223             my $fullpath = $fileinfo->{fullpath} //= $self->root->file($file);
224              
225             my $stat = $fullpath->stat;
226              
227             die "Failed to stat $fullpath" unless $stat;
228              
229             unless ( not $force_update and $fileinfo->{stat} and $fileinfo->{stat}->mtime == $stat->mtime ) {
230             $fileinfo->{mime} = $mimetypes->mimeTypeOf($file) // $default_mimetype;
231             delete $fileinfo->{data};
232             $fileinfo->{dependancies} = {};
233              
234             $fileinfo->{components} = do {
235             my $extension = "$file";
236             $extension =~ s/(.*)\.//;
237             {
238             file => "$file",
239             extension => $extension,
240             barename => $1,
241             fragment => $fileinfo->{components}{fragment},
242             }
243             };
244              
245             foreach my $plugin ( $self->plugins ) {
246             next unless $plugin->can('preprocess');
247             $plugin->can('preprocess')->($self, $file, $stat, $fileinfo);
248             }
249              
250             # Need to update this file
251             $fileinfo->{hash} = $self->hash_fileinfo($fileinfo);
252             $fileinfo->{components}{cdnfile} = join('.', $fileinfo->{components}{barename}, $fileinfo->{hash}, $fileinfo->{components}{extension});
253             $fileinfo->{components}{cdnfile} .= $fileinfo->{components}{fragment} if $fileinfo->{components}{fragment};
254             }
255             # TODO - need to check dependancies?
256              
257             $fileinfo->{stat} = $stat;
258              
259             return $fileinfo;
260             }
261              
262             sub hash_fileinfo {
263             my ($self, $fileinfo) = @_;
264              
265             return uc substr(Digest::MD5::md5_hex(scalar($self->_fileinfodata($fileinfo))), 0, 12);
266             }
267              
268             1;
269              
270              
271             __END__
272              
273             =head1 METHODS
274              
275             =head2 new
276              
277             Construct an object for generating URL paths and also for producing the
278             response content for a requested URL. The constructor accepts these names
279             options:
280              
281             =over 4
282              
283             =item root
284              
285             Filesystem path to the directory where your static files are stored.
286              
287             This option is required and has no default value.
288              
289             =item base
290              
291             URL path prefix to be added when generating unique URL paths. Defaults to
292             no prefix. A typical value might be '/cdn/'.
293              
294             =item plugins
295              
296             A list of plugins that you wish to enable. Default value is:
297             C<< [ 'HTTP::CDN::CSS' ] >>.
298              
299             =back
300              
301             =head2 resolve
302              
303             Takes a URL path of a file in the C<root> directory and returns a CDN URL with
304             C<base> prefix and content hash added.
305              
306             =head2 unhash_uri
307              
308             Takes a URI path and returns the same path with content hash removed. In list
309             context, the hash is also returned.
310              
311             Note: This method does not attempt to strip the C<base> prefix (e.g.: C</cdn/>)
312             from the URI path as that would usually have been done already by the
313             application framework's routing layer.
314              
315             =head2 fileinfo
316              
317             Takes a URI path (with hash removed) and returns a hash of information about
318             the file and its contents.
319              
320             =head2 filedata
321              
322             Takes a URI path (with hash removed) and returns the contents of that file with
323             any plug-in transformations applied.
324              
325             =head2 to_plack_app
326              
327             Returns a subroutine reference that can be used as a Plack application -
328             typically 'mounted' on a URL path like C</cdn/> as shown in the L<SYNOPSIS>.
329              
330              
331             =head1 AUTHOR
332              
333             Martyn Smith <martyn@dollyfish.net.nz>
334              
335              
336             =head1 COPYRIGHT AND LICENSE
337              
338             Copyright (C) 2010-2012 by Martyn Smith
339              
340             This library is free software; you can redistribute it and/or modify it under
341             the same terms as Perl itself.