File Coverage

blib/lib/Catalyst/ActionRole/Public.pm
Criterion Covered Total %
statement 52 52 100.0
branch 19 24 79.1
condition 1 2 50.0
subroutine 16 16 100.0
pod 0 6 0.0
total 88 100 88.0


line stmt bran cond sub pod time code
1             package Catalyst::ActionRole::Public;
2              
3 1     1   1575198 use Moose::Role;
  1         2  
  1         8  
4 1     1   3739 use Plack::Util ();
  1         1  
  1         12  
5 1     1   3 use Cwd ();
  1         6  
  1         7  
6 1     1   420 use Plack::MIME ();
  1         560  
  1         17  
7 1     1   400 use HTTP::Date ();
  1         2536  
  1         750  
8              
9             our $VERSION = '0.009';
10              
11             requires 'attributes','execute', 'match', 'match_captures',
12             'namespace', 'private_path', 'name';
13              
14             has at => (
15             is=>'ro',
16             required=>1,
17             lazy=>1,
18             builder=>'_build_at');
19              
20             sub _build_at {
21 8     8   42 my ($self) = @_;
22 8 100       11 my ($at) = @{$self->attributes->{At}||['/:privatepath/:args']};
  8         159  
23 8         242 return $at;
24             }
25              
26             has content_types => (
27             is=>'ro',
28             traits=>['Array'],
29             isa=>'ArrayRef[Str]',
30             lazy=>1,
31             builder=>'_build_content_type',
32             handles=>{
33             has_content_type => 'count',
34             filter_content_types => 'grep',
35             });
36              
37             sub _build_content_type {
38 8     8   15 my $self = shift;
39 8 100       8 my @ct = @{$self->attributes->{ContentType} || []};
  8         163  
40 8         311 return [ grep {defined $_} map { split(',', $_)} @ct];
  3         36  
  2         5  
41             }
42              
43             has show_debugging => (
44             is=>'ro',
45             required=>1,
46             lazy=>1,
47             builder=>'_build_show_debugging');
48              
49             sub _build_show_debugging {
50 8     8   12 my ($self) = @_;
51 8 50       151 return exists $self->attributes->{ShowDebugging} ? 1:0;
52             }
53              
54             has cache_control => (
55             is=>'ro',
56             lazy=>1,
57             builder=>'_build_cache_control');
58              
59             sub _build_cache_control {
60 1     1   2 my ($self) = @_;
61 1 50       20 if(exists $self->attributes->{CacheControl}) {
62 1 50       7 my @cc = @{$self->attributes->{CacheControl} || []};
  1         19  
63 1         32 return join ',', @cc;
64             }
65             }
66              
67 8 100   8 0 476 sub has_cache_control { shift->attributes->{CacheControl} ? 1:0 }
68              
69             #need to be able to do like /{:private_path}.html
70             sub expand_at_template {
71 8     8 0 31 my ($self, $at, %args) = @_;
72 21 100       71 return my @at_parts =
73 21 100       51 map { ref $_ ? @$_ : $_ }
74 8         31 map { defined($args{$_}) ? $args{$_} : $_ }
75             split('/', $at);
76             }
77              
78             sub expand_if_relative_path {
79 8     8 0 15 my ($self, @path_parts) = @_;
80 8 100       19 unless($path_parts[0]) {
81 7         27 return @path_parts[1..$#path_parts];
82             } else {
83 1         20 return (split('/', $self->private_path), @path_parts);
84             }
85             }
86              
87             sub is_real_file {
88 8     8 0 13 my ($self, $path) = @_;
89 8 50       43 return -f $path ? 1:0;
90             }
91              
92             sub evil_args {
93 8     8 0 14 my ($self, @args) = @_;
94 8         15 foreach my $arg(@args) {
95 5 50       15 return 1 if $arg eq '..';
96             }
97 8         23 return 0;
98             }
99              
100             sub path_is_allowed_content_type {
101 8     8 0 11 my ($self, $full_path) = @_;
102 8 100       280 if($self->has_content_type) {
103 1   50 3   39 return scalar($self->filter_content_types(sub { lc(Plack::MIME->mime_type($full_path)||'') eq lc($_) }));
  3         8  
104             } else {
105 7         26 return 1;
106             }
107             }
108              
109             around ['match', 'match_captures'] => sub {
110             my ($orig, $self, $ctx, $captures, @more) = @_;
111             return 0 unless $self->$orig($ctx, $captures, @more);
112             # ->match does not get args :( but ->match_captures get captures...
113             my @args = defined($captures) ? @$captures : @{$ctx->req->args||[]};
114             return 0 if($self->evil_args(@args));
115              
116             my %template_args = (
117             ':namespace' => $self->namespace,
118             ':privatepath' => $self->private_path,
119             ':private_path' => $self->private_path,
120             ':actionname' => $self->name,
121             ':action_name' => $self->name,
122             ':args' => \@args,
123             '*' => \@args);
124              
125             my @path_parts = $self->expand_if_relative_path(
126             $self->expand_at_template($self->at, %template_args));
127              
128             $ctx->stash(public_file_path =>
129             (my $full_path = $ctx->config->{root}->file(@path_parts)));
130              
131             unless($self->path_is_allowed_content_type($full_path)) {
132             $ctx->log->debug("File '$full_path' is not allowed content-type") if $ctx->debug;
133             return 0;
134             }
135            
136             if($self->is_real_file($full_path)) {
137             $ctx->log->debug("Serving File: $full_path") if $ctx->debug;
138             return $self->$orig($ctx, $captures);
139             } else {
140             $ctx->log->debug("File Not Found: $full_path") if $ctx->debug;
141             return 0;
142             }
143             };
144              
145             around 'execute', sub {
146             my ($orig, $self, $controller, $ctx, @args) = @_;
147             $ctx->log->abort(1) unless $self->show_debugging;
148             my $fh = $ctx->stash->{public_file_path}->openr;
149             Plack::Util::set_io_path($fh, Cwd::realpath($ctx->stash->{public_file_path}));
150              
151             my $stat = $ctx->stash->{public_file_path}->stat;
152             my $content_type = Plack::MIME->mime_type($ctx->stash->{public_file_path})
153             || 'application/octet';
154              
155             $ctx->res->from_psgi_response([
156             200,
157             [
158             'Content-Type' => $content_type,
159             'Content-Length' => $stat->[7],
160             'Last-Modified' => HTTP::Date::time2str( $stat->[9] ),
161             ($self->has_cache_control ? ('Cache-Control' => $self->cache_control):()),
162             ],
163             $fh]);
164              
165             return $self->$orig($controller, $ctx, @args);
166             };
167              
168             1;
169              
170             =head1 NAME
171              
172             Catalyst::ActionRole::Public - Mount a public url to files in your project directory.
173              
174             =head1 SYNOPSIS
175              
176             package MyApp::Controller::Root;
177              
178             use Moose;
179             use MooseX::MethodAttributes;
180              
181             sub static :Local Does(Public) At(/:actionname/*) { ... }
182              
183             __PACKAGE__->config(namespace=>'');
184              
185             Will create an action that from URL 'localhost/static/a/b/c/d.js' will serve
186             file $c->config->{root} . '/static' . '/a/b/c/d.js'. Will also set content type, length
187             and Last-Modified HTTP headers as needed. If the file does not exist, will not
188             match (allowing possibly other actions to match).
189              
190             =head1 DESCRIPTION
191              
192             Use this actionrole to map a public facing URL attached to an action to a file
193             (or files) on the filesystem, off the $c->config->{root} directory. If the file does
194             not exist, the action will not match. No default 'notfound' page is created,
195             unlike L<Plack::App::File> or L<Catalyst::Plugin::Static::Simple>. The action
196             method body may be used to modify the response before finalization.
197              
198             A template may be constructed to determine how we map an incoming request to
199             a path on the filesystem. You have extensive control how an incoming HTTP
200             request maps to a file on the filesystem. You can even use this action role
201             in the middle of a chained style action (although its hard to imagine the
202             use case for that...)
203              
204             =head2 ACTION METHOD BODY
205              
206             The body of your action will be executed after we've created a filehandle to
207             the found file and setup the response. You may leave it empty, or if you want
208             to do additional logging or work, you can. Also, you will find a stash key
209             C<public_file_path> has been populated with a L<Path::Class> object which is
210             pointing to the found file. The action method body will not be executed if
211             the file associated with the action does not exist.
212              
213             =head1 ACTION ATTRIBUTES
214              
215             Actions the consume this role provide the following subroutine attributes.
216              
217             =head2 ShowDebugging
218              
219             Enabled developer debugging output. Example:
220              
221             sub myaction :Local Does(Public) ShowDebugging { ... }
222              
223             If present do not surpress the extra developer mode debugging information. Useful
224             if you have trouble serving files and you can't figure out why.
225              
226             =head2 At
227              
228             Used to set the action class template used to match files on the filesystem to
229             incoming requests. Examples:
230              
231             package MyApp::Controller::Basic;
232              
233             use Moose;
234             use MooseX::MethodAttributes;
235              
236             extends 'Catalyst::Controller';
237              
238             #localhost/basic/css => $c->config->{root} .'/basic/*'
239             sub css :Local Does(Public) At(/:namespace/*) { }
240              
241             #localhost/basic/static => $c->config->{root} .'/basic/static/*'
242             sub static :Local Does(Public) { }
243              
244             #localhost/basic/111/aaa/link2/333/444.txt => $c->config->{root} .'/basic/link2/333/444.txt'
245             sub chainbase :Chained(/) PathPrefix CaptureArgs(1) { }
246              
247             sub link1 :Chained(chainbase) PathPart(aaa) CaptureArgs(0) { }
248              
249             sub link2 :Chained(link1) Args(2) Does(Public) { }
250              
251             #localhost/chainbase2/111/aaa/222.txt/link4/333 => $c->config->{root} . '/basic/link3/222.txt'
252             sub chainbase2 :Chained(/) CaptureArgs(1) { }
253              
254             sub link3 :Chained(chainbase2) PathPart(aaa) CaptureArgs(1) Does(Public) { }
255              
256             sub link4 :Chained(link3) Args(1) { }
257              
258             1;
259              
260             B<NOTE:> You're template may be 'relative or absolute' to the $c->config->{root} value
261             based on if the first character in the template is '/' or not. If it is '/'
262             that is an 'absolute' template which will be added to $c->config->{root}. Generally
263             if you are making a template this is what you want. However if you don't have
264             a '/' prepended to the start of your template (such as in At(file.txt)) we then
265             make your filesystem lookup relative to the action private path. So in the
266             example:
267              
268             package MyApp::Controller::Basic;
269              
270             sub absolute_path :Path('/example1') Does(Public) At(/example.txt) { }
271             sub relative_path :Path('/example2') Does(Public) At(example.txt) { }
272              
273             Then http://localhost/example1 => $c->config->{root} . '/example.txt' but
274             http://localhost/example2 => $c->config->{root} . '/basic/relative_path/example.txt'.
275             You may find this a useful "DWIW" when an action is linked to a particular file.
276              
277             B<NOTE:> The following expansions are recognized in your C<At> declaration:
278              
279             =over 4
280              
281             =item :namespace
282              
283             The action namespace, determined from the containing controller. Usually this
284             is based on the Controller package name but you can override it via controller
285             configuration. For example:
286              
287             package MyApp::Controller::Foo::Bar::Baz;
288              
289             Has a namespace of 'foo/bar/baz' by default.
290              
291             =item :privatepath
292              
293             =item :private_path
294              
295             The action private_path value. By default this is the namespace + the action
296             name. For example:
297              
298             package MyApp::Controller::Foo::Bar::Baz;
299              
300             sub myaction :Path('abcd') { ... }
301              
302             The action C<myaction> has a private_path of '/foo/bar/baz/myaction'.
303              
304             B<NOTE:> the expansion C<:private_path> is mapped to this value as well.
305              
306             =item actionname
307              
308             =item action_name
309              
310             The name of the action (typically the subroutine name)
311              
312             sub static :Local Does(Public) At(/:actionname/*) { ... }
313              
314             In this case actionname = 'static'
315              
316             =item :args
317              
318             =item '*'
319              
320             The arguments to the request. For example:
321              
322             Package MyApp::Controller::Static;
323              
324             sub myfiles :Path('') Does(Public) At(/:namespace/*) { ... }
325              
326             Would map 'http://localhost/static/a/b/c/d.txt' to $c->config->{root} . '/static/a/b/c/d.txt'.
327              
328             In this case $args = ['a', 'b', 'c', 'd.txt']
329              
330             =back
331              
332             =head2 ContentType
333              
334             Used to set the response Content-Type header and match the file extension. Example:
335              
336             sub myaction :Local Does(Public) ContentType(application/javascript) { ... }
337              
338             By default we inspect the request URL extension and set a content type based on
339             the extension text (defaulting to 'application/octet' if we cannot determine). If
340             you set this to a MIME type, we will always set the response content type based on
341             this. Also, we will only match static files on the filesystem whose extensions
342             match the declared type.
343              
344             You may declare more than one ContentType, in which case all allowed types are
345             permitted in the match.
346              
347             =head2 CacheControl
348              
349             Used to set the Cache-Control HTTP header (useful for caching your static assets).
350             Example:
351              
352             sub myaction :Local Does(Public) CacheControl(public, max-age=600) { ...}
353              
354             =head1 RESPONSE INFO
355              
356             If we find a file we serve the filehandle directly to you plack handler, and set
357             a 'with_path' value so that you can use this with something like L<Plack::Middleware::XSendfile>.
358             We also set the Content-Type, Content-Length and Last-Modified headers. If you
359             need to add more information before finalizing the response you may do so with
360             the matching action metod body.
361              
362             =head1 COOKBOOK
363              
364             I often use this in a Root.pm controller like:
365              
366             package MyApp::Web::Controller::Root;
367              
368             use Moose;
369             use MooseX::MethodAttributes;
370             use HTTP::Exception;
371              
372             extends 'Catalyst::Controller';
373              
374             sub root :Chained(/) PathPart('') CaptureArgs(0) {
375             my ($self, $c) = @_;
376             }
377              
378             sub index :Chained(root) PathPart('') Args(0) {
379             my ($self, $c) = @_;
380             }
381              
382             sub css :Chained(root) Args Does(Public) ContentType(text/css) { }
383             sub js :Chained(root) Args Does(Public) ContentType(application/javascript) { }
384             sub img :Chained(root) Args Does(Public) { }
385             sub html :Chained(root) PathPart('') Args Does(Public) At(/:args) ContentType(text/html,text/plain) { }
386              
387             sub default :Default { HTTP::Exception->throw(404) }
388             sub end :ActionClass(RenderView) { }
389              
390             __PACKAGE__->config(namespace=>'');
391             __PACKAGE__->meta->make_immutable;
392              
393             This sets up to let me mix my templates and static files under $c->config->{root} and in
394             general prevents non asset types from being accidentally posted. I might then
395             have a directory of files like:
396              
397             root/
398             css/
399             js/
400             img/
401             index.html
402             welcome.template
403              
404             FWIW!
405              
406             =head1 AUTHOR
407            
408             John Napiorkowski L<email:jjnapiork@cpan.org>
409            
410             =head1 SEE ALSO
411            
412             L<Catalyst>, L<Catalyst::Controller>, L<Plack::App::Directory>,
413             L<Catalyst::Controller::Assets>.
414            
415             =head1 COPYRIGHT & LICENSE
416            
417             Copyright 2015, John Napiorkowski L<email:jjnapiork@cpan.org>
418            
419             This library is free software; you can redistribute it and/or modify it under
420             the same terms as Perl itself.
421              
422             =cut
423              
424             __END__
425              
426             ContentType