File Coverage

blib/lib/Catalyst/ActionRole/Public.pm
Criterion Covered Total %
statement 52 52 100.0
branch 20 24 83.3
condition 4 5 80.0
subroutine 16 16 100.0
pod 0 6 0.0
total 92 103 89.3


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