use warnings; use strict; use File::MMagic (); use MIME::Types (); use Compress::Zlib (); use HTTP::Date (); package Jifty::View::Static::Handler; use base qw/Jifty::View/; our ($MIME,$MAGIC); =head1 NAME Jifty::View::Static::Handler - Jifty view handler for static files =head1 DESCRIPTION This class takes care of serving out static files for a Jifty application. When fully operational, it will use an algorithm along the lines of the following: * Static files are served out of a separate root * If static files go through apache: * How do we merge together the N static roots? * If static files go through Jifty::Handler * We need a flag to allow them to go through the dispatcher, too * return "True" (304) for if-modified-since * if the browser accepts gzipped data, see if we have a cached gzipped copy if so, send it see if we have a marker indicating that gzip is a lose if so, send uncompressed gzip the content send the gzipped content * if the browser doesn't accept gzipped content send the content uncompressed =head2 new Create a new static file handler. Likely, only the C needs to do this. =cut sub new { my $class = shift; my @roots = (Jifty->config->framework('Web')->{StaticRoot}); my %seen; $seen{$_} = 1 for map Jifty->config->framework('Web')->{$_}, qw/StaticRoot DefaultStaticRoot/; for my $plugin ( Jifty->plugins ) { for my $root ($plugin->static_root) { next unless ( defined $root and -d $root and -r $root and not $seen{$root}++); push @roots, $root; $plugin->log->debug( "Plugin @{[ref($plugin)]} static root added: (@{[$root ||'']})"); } } push @roots, (Jifty->config->framework('Web')->{DefaultStaticRoot}); return bless { roots => \@roots }, $class; } =head2 roots Returns all the static roots the handler will search =cut sub roots { my $self = shift; return wantarray ? @{$self->{roots}} : $self->{roots}; } =head2 show $path Handle a request for C<$path>. If we can't find a static file of that name, return undef. =head2 handle_request $path An alias for L =cut sub show { shift->handle_request(@_); } sub handle_request { my $self = shift; my $path = shift; my $local_path = $self->file_path($path) or return undef; if ( my $since = Jifty->handler->cgi->http('If-Modified-Since') ) { my @file_info = stat($local_path); # IE appends "; length=N" to If-Modified-Since headers and we need # to get rid of it so str2time doesn't choke below $since =~ s/;.+$//; return $self->send_not_modified unless $file_info[9] > HTTP::Date::str2time($since); } my $mime_type = $self->mime_type($local_path); if ( $self->client_accepts_gzipped_content and $mime_type =~ m!^(text/|application/x-javascript)! ) { return $self->send_file($local_path, $mime_type, 'gzip'); } else { return $self->send_file($local_path, $mime_type, 'uncompressed'); } } =head2 client_accepts_gzipped_content Returns true if it looks like the client accepts gzip encoding. Otherwise, returns false. =cut sub client_accepts_gzipped_content { my $self = shift; no warnings 'uninitialized'; return Jifty->handler->cgi->http('Accept-Encoding') =~ /\bgzip\b/; } =head2 file_path $path Returns the system path for C<$path>, searching inside the application's static root, loaded plugins' static roots, and finally Jifty's static root. Returns undef if it can't find the file in any path. =cut =head2 template_exists $path An alias for L. =cut sub template_exists { my $class = shift; my $template = shift; return $template if $class->file_path($template); return undef; } sub file_path { my $self = shift; my $file = shift; # Chomp a leading "/static" - should this be configurable? $file =~ s/^\/*?static//; foreach my $path ( $self->roots ) { my $abspath = Jifty::Util->absolute_path( File::Spec->catdir($path,$file )); # If the user is trying to request something outside our static root, # decline the request my $abs_base_path = Jifty::Util->absolute_path( $path ); unless ($abspath =~ /^\Q$abs_base_path\E/) { return undef; } return $abspath if ( -f $abspath && -r $abspath ); } return undef; } =head2 mime_type $path Returns the mime type of the file whose path on disk is C<$path>. Tries to use L to guess first. If that fails, it falls back to C. =cut sub mime_type { my $self = shift; my $local_path = shift; # The key is the file extension, the value is the MIME type to send. my %type_override = ( # MIME::Types returns application/javascript for .js, but Opera # chokes on ajax-fetched JS that has a type other than the one below # JSAN.js fetches JS via Ajax when it loads JSAN modules 'js' => 'application/x-javascript', 'json' => 'application/json; charset=UTF-8', 'htc' => 'text/x-component', ); return ($type_override{$1}) if $local_path =~ /^.*\.(.+?)$/ and defined $type_override{$1}; # Defer initialization to first use. (It's not actually cheap) $MIME ||= MIME::Types->new(); $MAGIC ||= File::MMagic->new(); my $mimeobj = $MIME->mimeTypeOf($local_path); my $mime_type = ( $mimeobj ? $mimeobj->type : $MAGIC->checktype_filename($local_path)); return ($mime_type); } =head2 send_file $path $mimetype $compression Print C<$path> to STDOUT (the client), identified with a mimetype of C<$mimetype>. If C<$compression> is C, gzip the output stream. =cut sub send_file { my $self = shift; my $local_path = shift; my $mime_type = shift; my $compression = shift; my $fh = IO::File->new( $local_path, 'r' ); if ( defined $fh ) { binmode $fh; # This is designed to work under CGI or FastCGI; will need an # abstraction for mod_perl # Clear out the mason output, if any Jifty->web->mason->clear_buffer if Jifty->web->mason; my @file_info = stat($local_path); my $apache = Jifty->handler->apache; $apache->content_type($mime_type); $self->send_http_header( $compression, $file_info[7], $file_info[9] ); if ( $compression eq 'gzip' ) { local $/; binmode STDOUT; # XXX TODO: Cache this print STDOUT Compress::Zlib::memGzip(<$fh>); } else { $apache->send_fd($fh); } close($fh); return 1; } else { return undef; } } =head2 send_http_header [COMPRESSION, LENGTH, LAST_MODIFIED] Sends appropriate cache control and expiration headers such that the client will cache the content. =cut sub send_http_header { my $self = shift; my ($compression, $length, $modified) = @_; my $now = time(); my $apache = Jifty->handler->apache; $apache->header_out( Status => 200 ); # Expire in a year $apache->header_out( 'Cache-Control' => 'max-age=31536000, public' ); $apache->header_out( 'Expires' => HTTP::Date::time2str( $now + 31536000 ) ); $apache->header_out( 'Last-Modified' => HTTP::Date::time2str( $modified ) ) if $modified; $apache->header_out( 'Content-Length' => $length ) unless ( $compression eq 'gzip' ); $apache->header_out( 'Content-Encoding' => "gzip" ) if ( $compression eq 'gzip' ); Jifty->handler->send_http_header; } =head2 send_not_modified Sends a "304 Not modified" response to the browser, telling it to use a cached copy. =cut sub send_not_modified { my $self = shift; my $apache = Jifty->handler->apache; $apache->header_out( Status => 304 ); Jifty->handler->send_http_header; return 1; } 1;