Static::Simple: Added serve_static_file patch from groditi, plus tests
[catagits/Catalyst-Plugin-Static-Simple.git] / lib / Catalyst / Plugin / Static / Simple.pm
1 package Catalyst::Plugin::Static::Simple;
2
3 use strict;
4 use warnings;
5 use base qw/Class::Accessor::Fast Class::Data::Inheritable/;
6 use File::stat;
7 use File::Spec ();
8 use IO::File ();
9 use MIME::Types ();
10
11 our $VERSION = '0.17';
12
13 __PACKAGE__->mk_accessors( qw/_static_file _static_debug_message/ );
14
15 sub prepare_action {
16     my $c = shift;
17     my $path = $c->req->path;
18     my $config = $c->config->{static};
19     
20     $path =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
21
22     # is the URI in a static-defined path?
23     foreach my $dir ( @{ $config->{dirs} } ) {
24         my $dir_re = quotemeta $dir;
25         my $re = ( $dir =~ m{^qr/}xms ) ? eval $dir : qr/^${dir_re}/;
26         if ($@) {
27             $c->error( "Error compiling static dir regex '$dir': $@" );
28         }
29         if ( $path =~ $re ) {
30             if ( $c->_locate_static_file( $path, 1 ) ) {
31                 $c->_debug_msg( 'from static directory' )
32                     if $config->{debug};
33             } else {
34                 $c->_debug_msg( "404: file not found: $path" )
35                     if $config->{debug};
36                 $c->res->status( 404 );
37             }
38         }
39     }
40     
41     # Does the path have an extension?
42     if ( $path =~ /.*\.(\S{1,})$/xms ) {
43         # and does it exist?
44         $c->_locate_static_file( $path );
45     }
46     
47     return $c->NEXT::ACTUAL::prepare_action(@_);
48 }
49
50 sub dispatch {
51     my $c = shift;
52     
53     return if ( $c->res->status != 200 );
54     
55     if ( $c->_static_file ) {
56         if ( $c->config->{static}{no_logs} && $c->log->can('abort') ) {
57            $c->log->abort( 1 );
58         }
59         return $c->_serve_static;
60     }
61     else {
62         return $c->NEXT::ACTUAL::dispatch(@_);
63     }
64 }
65
66 sub finalize {
67     my $c = shift;
68     
69     # display all log messages
70     if ( $c->config->{static}{debug} && scalar @{$c->_debug_msg} ) {
71         $c->log->debug( 'Static::Simple: ' . join q{ }, @{$c->_debug_msg} );
72     }
73     
74     return $c->NEXT::ACTUAL::finalize(@_);
75 }
76
77 sub setup {
78     my $c = shift;
79     
80     $c->NEXT::setup(@_);
81     
82     if ( Catalyst->VERSION le '5.33' ) {
83         require File::Slurp;
84     }
85     
86     my $config = $c->config->{static} ||= {};
87     
88     $config->{dirs} ||= [];
89     $config->{include_path} ||= [ $c->config->{root} ];
90     $config->{mime_types} ||= {};
91     $config->{ignore_extensions} ||= [ qw/tmpl tt tt2 html xhtml/ ];
92     $config->{ignore_dirs} ||= [];
93     $config->{debug} ||= $c->debug;
94     $config->{no_logs} = 1 unless defined $config->{no_logs};
95     
96     # load up a MIME::Types object, only loading types with
97     # at least 1 file extension
98     $config->{mime_types_obj} = MIME::Types->new( only_complete => 1 );
99     
100     # preload the type index hash so it's not built on the first request
101     $config->{mime_types_obj}->create_type_index;
102 }
103
104 # Search through all included directories for the static file
105 # Based on Template Toolkit INCLUDE_PATH code
106 sub _locate_static_file {
107     my ( $c, $path, $in_static_dir ) = @_;
108     
109     $path = File::Spec->catdir(
110         File::Spec->no_upwards( File::Spec->splitdir( $path ) ) 
111     );
112     
113     my $config = $c->config->{static};
114     my @ipaths = @{ $config->{include_path} };
115     my $dpaths;
116     my $count = 64; # maximum number of directories to search
117     
118     DIR_CHECK:
119     while ( @ipaths && --$count) {
120         my $dir = shift @ipaths || next DIR_CHECK;
121         
122         if ( ref $dir eq 'CODE' ) {
123             eval { $dpaths = &$dir( $c ) };
124             if ($@) {
125                 $c->log->error( 'Static::Simple: include_path error: ' . $@ );
126             } else {
127                 unshift @ipaths, @$dpaths;
128                 next DIR_CHECK;
129             }
130         } else {
131             $dir =~ s/(\/|\\)$//xms;
132             if ( -d $dir && -f $dir . '/' . $path ) {
133                 
134                 # Don't ignore any files in static dirs defined with 'dirs'
135                 unless ( $in_static_dir ) {
136                     # do we need to ignore the file?
137                     for my $ignore ( @{ $config->{ignore_dirs} } ) {
138                         $ignore =~ s{(/|\\)$}{};
139                         if ( $path =~ /^$ignore(\/|\\)/ ) {
140                             $c->_debug_msg( "Ignoring directory `$ignore`" )
141                                 if $config->{debug};
142                             next DIR_CHECK;
143                         }
144                     }
145                 
146                     # do we need to ignore based on extension?
147                     for my $ignore_ext ( @{ $config->{ignore_extensions} } ) {
148                         if ( $path =~ /.*\.${ignore_ext}$/ixms ) {
149                             $c->_debug_msg( "Ignoring extension `$ignore_ext`" )
150                                 if $config->{debug};
151                             next DIR_CHECK;
152                         }
153                     }
154                 }
155                 
156                 $c->_debug_msg( 'Serving ' . $dir . '/' . $path )
157                     if $config->{debug};
158                 return $c->_static_file( $dir . '/' . $path );
159             }
160         }
161     }
162     
163     return;
164 }
165
166 sub _serve_static {
167     my $c = shift;
168            
169     my $full_path = shift || $c->_static_file;
170     my $type      = $c->_ext_to_type( $full_path );
171     my $stat      = stat $full_path;
172
173     $c->res->headers->content_type( $type );
174     $c->res->headers->content_length( $stat->size );
175     $c->res->headers->last_modified( $stat->mtime );
176
177     if ( Catalyst->VERSION le '5.33' ) {
178         # old File::Slurp method
179         my $content = File::Slurp::read_file( $full_path );
180         $c->res->body( $content );
181     }
182     else {
183         # new method, pass an IO::File object to body
184         my $fh = IO::File->new( $full_path, 'r' );
185         if ( defined $fh ) {
186             binmode $fh;
187             $c->res->body( $fh );
188         }
189         else {
190             Catalyst::Exception->throw( 
191                 message => "Unable to open $full_path for reading" );
192         }
193     }
194     
195     return 1;
196 }
197
198 sub serve_static_file {
199     my ( $c, $full_path ) = @_;
200
201     my $config = $c->config->{static} ||= {};
202     
203     if ( -e $full_path ) {
204         $c->_debug_msg( "Serving static file: $full_path" )
205             if $config->{debug};
206     }
207     else {
208         $c->_debug_msg( "404: file not found: $full_path" )
209             if $config->{debug};
210         $c->res->status( 404 );
211         return;
212     }
213
214     $c->_serve_static( $full_path );
215 }
216
217 # looks up the correct MIME type for the current file extension
218 sub _ext_to_type {
219     my ( $c, $full_path ) = @_;
220     
221     my $config = $c->config->{static};
222     
223     if ( $full_path =~ /.*\.(\S{1,})$/xms ) {
224         my $ext = $1;
225         my $type = $config->{mime_types}{$ext} 
226             || $config->{mime_types_obj}->mimeTypeOf( $ext );
227         if ( $type ) {
228             $c->_debug_msg( "as $type" ) if $config->{debug};
229             return ( ref $type ) ? $type->type : $type;
230         }
231         else {
232             $c->_debug_msg( "as text/plain (unknown extension $ext)" )
233                 if $config->{debug};
234             return 'text/plain';
235         }
236     }
237     else {
238         $c->_debug_msg( 'as text/plain (no extension)' )
239             if $config->{debug};
240         return 'text/plain';
241     }
242 }
243
244 sub _debug_msg {
245     my ( $c, $msg ) = @_;
246     
247     if ( !defined $c->_static_debug_message ) {
248         $c->_static_debug_message( [] );
249     }
250     
251     if ( $msg ) {
252         push @{ $c->_static_debug_message }, $msg;
253     }
254     
255     return $c->_static_debug_message;
256 }
257
258 1;
259 __END__
260
261 =head1 NAME
262
263 Catalyst::Plugin::Static::Simple - Make serving static pages painless.
264
265 =head1 SYNOPSIS
266
267     use Catalyst;
268     MyApp->setup( qw/Static::Simple/ );
269     # that's it; static content is automatically served by
270     # Catalyst, though you can configure things or bypass
271     # Catalyst entirely in a production environment
272
273 =head1 DESCRIPTION
274
275 The Static::Simple plugin is designed to make serving static content in
276 your application during development quick and easy, without requiring a
277 single line of code from you.
278
279 This plugin detects static files by looking at the file extension in the
280 URL (such as B<.css> or B<.png> or B<.js>). The plugin uses the
281 lightweight L<MIME::Types> module to map file extensions to
282 IANA-registered MIME types, and will serve your static files with the
283 correct MIME type directly to the browser, without being processed
284 through Catalyst.
285
286 Note that actions mapped to paths using periods (.) will still operate
287 properly.
288
289 Though Static::Simple is designed to work out-of-the-box, you can tweak
290 the operation by adding various configuration options. In a production
291 environment, you will probably want to use your webserver to deliver
292 static content; for an example see L<USING WITH APACHE>, below.
293
294 =head1 DEFAULT BEHAVIOR
295
296 By default, Static::Simple will deliver all files having extensions
297 (that is, bits of text following a period (C<.>)), I<except> files
298 having the extensions C<tmpl>, C<tt>, C<tt2>, C<html>, and
299 C<xhtml>. These files, and all files without extensions, will be
300 processed through Catalyst. If L<MIME::Types> doesn't recognize an
301 extension, it will be served as C<text/plain>.
302
303 To restate: files having the extensions C<tmpl>, C<tt>, C<tt2>, C<html>,
304 and C<xhtml> I<will not> be served statically by default, they will be
305 processed by Catalyst. Thus if you want to use C<.html> files from
306 within a Catalyst app as static files, you need to change the
307 configuration of Static::Simple. Note also that files having any other
308 extension I<will> be served statically, so if you're using any other
309 extension for template files, you should also change the configuration.
310
311 Logging of static files is turned off by default.
312
313 =head1 ADVANCED CONFIGURATION
314
315 Configuration is completely optional and is specified within
316 C<MyApp-E<gt>config-E<gt>{static}>.  If you use any of these options,
317 this module will probably feel less "simple" to you!
318
319 =head2 Enabling request logging
320
321 Since Catalyst 5.50, logging of static requests is turned off by
322 default; static requests tend to clutter the log output and rarely
323 reveal anything useful. However, if you want to enable logging of static
324 requests, you can do so by setting
325 C<MyApp-E<gt>config-E<gt>{static}-E<gt>{no_logs}> to 0.
326
327 =head2 Forcing directories into static mode
328
329 Define a list of top-level directories beneath your 'root' directory
330 that should always be served in static mode.  Regular expressions may be
331 specified using C<qr//>.
332
333     MyApp->config->{static}->{dirs} = [
334         'static',
335         qr/^(images|css)/,
336     ];
337
338 =head2 Including additional directories
339
340 You may specify a list of directories in which to search for your static
341 files. The directories will be searched in order and will return the
342 first file found. Note that your root directory is B<not> automatically
343 added to the search path when you specify an C<include_path>. You should
344 use C<MyApp-E<gt>config-E<gt>{root}> to add it.
345
346     MyApp->config->{static}->{include_path} = [
347         '/path/to/overlay',
348         \&incpath_generator,
349         MyApp->config->{root}
350     ];
351     
352 With the above setting, a request for the file C</images/logo.jpg> will search
353 for the following files, returning the first one found:
354
355     /path/to/overlay/images/logo.jpg
356     /dynamic/path/images/logo.jpg
357     /your/app/home/root/images/logo.jpg
358     
359 The include path can contain a subroutine reference to dynamically return a
360 list of available directories.  This method will receive the C<$c> object as a
361 parameter and should return a reference to a list of directories.  Errors can
362 be reported using C<die()>.  This method will be called every time a file is
363 requested that appears to be a static file (i.e. it has an extension).
364
365 For example:
366
367     sub incpath_generator {
368         my $c = shift;
369         
370         if ( $c->session->{customer_dir} ) {
371             return [ $c->session->{customer_dir} ];
372         } else {
373             die "No customer dir defined.";
374         }
375     }
376     
377 =head2 Ignoring certain types of files
378
379 There are some file types you may not wish to serve as static files.
380 Most important in this category are your raw template files.  By
381 default, files with the extensions C<tmpl>, C<tt>, C<tt2>, C<html>, and
382 C<xhtml> will be ignored by Static::Simple in the interest of security.
383 If you wish to define your own extensions to ignore, use the
384 C<ignore_extensions> option:
385
386     MyApp->config->{static}->{ignore_extensions} 
387         = [ qw/html asp php/ ];
388     
389 =head2 Ignoring entire directories
390
391 To prevent an entire directory from being served statically, you can use
392 the C<ignore_dirs> option.  This option contains a list of relative
393 directory paths to ignore.  If using C<include_path>, the path will be
394 checked against every included path.
395
396     MyApp->config->{static}->{ignore_dirs} = [ qw/tmpl css/ ];
397     
398 For example, if combined with the above C<include_path> setting, this
399 C<ignore_dirs> value will ignore the following directories if they exist:
400
401     /path/to/overlay/tmpl
402     /path/to/overlay/css
403     /dynamic/path/tmpl
404     /dynamic/path/css
405     /your/app/home/root/tmpl
406     /your/app/home/root/css    
407
408 =head2 Custom MIME types
409
410 To override or add to the default MIME types set by the L<MIME::Types>
411 module, you may enter your own extension to MIME type mapping.
412
413     MyApp->config->{static}->{mime_types} = {
414         jpg => 'image/jpg',
415         png => 'image/png',
416     };
417
418 =head2 Compatibility with other plugins
419
420 Since version 0.12, Static::Simple plays nice with other plugins.  It no
421 longer short-circuits the C<prepare_action> stage as it was causing too
422 many compatibility issues with other plugins.
423
424 =head2 Debugging information
425
426 Enable additional debugging information printed in the Catalyst log.  This
427 is automatically enabled when running Catalyst in -Debug mode.
428
429     MyApp->config->{static}->{debug} = 1;
430     
431 =head1 USING WITH APACHE
432
433 While Static::Simple will work just fine serving files through Catalyst in
434 mod_perl, for increased performance, you may wish to have Apache handle the
435 serving of your static files.  To do this, simply use a dedicated directory
436 for your static files and configure an Apache Location block for that
437 directory.  This approach is recommended for production installations.
438
439     <Location /static>
440         SetHandler default-handler
441     </Location>
442
443 Using this approach Apache will bypass any handling of these directories
444 through Catalyst. You can leave Static::Simple as part of your
445 application, and it will continue to function on a development server,
446 or using Catalyst's built-in server.
447
448 =head1 PUBLIC METHODS
449
450 =head2 serve_static_file $file_path
451
452 Will serve the file located in $file_path statically. This is useful when
453 you need to  autogenerate them if they don't exist, or they are stored in a model.
454
455     package MyApp::Controller::User;
456
457     sub curr_user_thumb : PathPart("my_thumbnail.png") {
458         my ( $self, $c ) = @_;
459         my $file_path = $c->user->picture_thumbnail_path;
460         $c->serve_static_file($file_path);
461     }
462
463 =head1 INTERNAL EXTENDED METHODS
464
465 Static::Simple extends the following steps in the Catalyst process.
466
467 =head2 prepare_action 
468
469 C<prepare_action> is used to first check if the request path is a static
470 file.  If so, we skip all other C<prepare_action> steps to improve
471 performance.
472
473 =head2 dispatch
474
475 C<dispatch> takes the file found during C<prepare_action> and writes it
476 to the output.
477
478 =head2 finalize
479
480 C<finalize> serves up final header information and displays any log
481 messages.
482
483 =head2 setup
484
485 C<setup> initializes all default values.
486
487 =head1 SEE ALSO
488
489 L<Catalyst>, L<Catalyst::Plugin::Static>, 
490 L<http://www.iana.org/assignments/media-types/>
491
492 =head1 AUTHOR
493
494 Andy Grundman, <andy@hybridized.org>
495
496 =head1 CONTRIBUTORS
497
498 Marcus Ramberg, <mramberg@cpan.org>
499
500 Jesse Sheidlower, <jester@panix.com>
501
502 Guillermo Roditi, <groditi@cpan.org>
503
504 =head1 THANKS
505
506 The authors of Catalyst::Plugin::Static:
507
508     Sebastian Riedel
509     Christian Hansen
510     Marcus Ramberg
511
512 For the include_path code from Template Toolkit:
513
514     Andy Wardley
515
516 =head1 COPYRIGHT
517
518 This program is free software, you can redistribute it and/or modify it under
519 the same terms as Perl itself.
520
521 =cut