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