File Coverage

blib/lib/MVC/Neaf/X/Files.pm
Criterion Covered Total %
statement 125 129 96.9
branch 37 52 71.1
condition 18 26 69.2
subroutine 19 20 95.0
pod 9 9 100.0
total 208 236 88.1


line stmt bran cond sub pod time code
1             package MVC::Neaf::X::Files;
2              
3 11     11   159158 use strict;
  11         54  
  11         365  
4 11     11   64 use warnings;
  11         27  
  11         582  
5             our $VERSION = '0.29';
6              
7             =head1 NAME
8              
9             MVC::Neaf::X::Files - serve static content for Not Even A Framework.
10              
11             =head1 SYNOPSIS
12              
13             use MVC::Neaf;
14              
15             neaf static "/path/in/url" => "/local/path", %options;
16              
17             These options would go to this module's new() method described below.
18              
19             =head1 DESCRIPTION
20              
21             Serving static content in production via a perl application framework
22             is a bad idea.
23             However, forcing the user to run a separate web-server just to test
24             their CSS, JS, and images is an even worse one.
25              
26             So this module is here to fill the gap in L.
27              
28             =head1 METHODS
29              
30             =cut
31              
32 11     11   88 use Cwd qw(abs_path);
  11         24  
  11         632  
33 11     11   68 use File::Basename;
  11         31  
  11         795  
34 11     11   1151 use Encode;
  11         30519  
  11         1065  
35              
36 11     11   1006 use MVC::Neaf::Util qw(http_date canonize_path);
  11         50  
  11         615  
37 11     11   3614 use MVC::Neaf::View::TT;
  11         28  
  11         403  
38 11     11   75 use parent qw(MVC::Neaf::X);
  11         23  
  11         53  
39              
40             # Enumerate most common file types. Patches welcome.
41             our %ExtType = (
42             css => 'text/css',
43             gif => 'image/gif',
44             htm => 'text/html',
45             html => 'text/html',
46             jpeg => 'image/jpeg',
47             jpg => 'image/jpeg',
48             js => 'application/javascript',
49             pl => 'text/plain',
50             png => 'image/png',
51             txt => 'text/plain',
52             );
53              
54             =head2 new( %options )
55              
56             %options may include:
57              
58             =over
59              
60             =item * root - where to search for files. May point to asingle file, too.
61             (Required).
62              
63             =item * buffer - buffer size for serving files.
64             Currently this is also the size below which in-memory caching is on,
65             but this MAY change in the future.
66              
67             =item * cache_ttl - if given, files below the buffer size will be stored
68             in memory for cache_ttl seconds.
69             B. Cache API is not yet established.
70              
71             =item * in_memory = { name => [ "content", "type" ] }
72              
73             Serve some files from memory.
74             Content-type defaults to text/plain.
75              
76             B. Name and signature MAY change in the future.
77              
78             =back
79              
80             =cut
81              
82             my $dir_template = <<"HTML";
83            
84            
85             Directory index of [% path | html %]
86            
87            
88            

Directory index of [% path | html %]

89            

Generated on [% date | html %]

90             [% IF updir.length %]
91             Parent directory
92             [% END %]
93            
94             [% FOREACH item IN list %]
95            
96             [% IF item.dir %]DIR[% END %]
97             [% item.name | html %]
98             [% IF !item.dir %][% item.size %][% END %]
99             [% item.lastmod %]
100            
101             [% END # FOREACH %]
102            
103            
104            
105             HTML
106              
107             my %static_options;
108             $static_options{$_}++ for qw(
109             root base_url in_memory
110             description buffer cache_ttl allow_dots dir_index dir_template view );
111              
112             sub new {
113 13     13 1 3610 my ($class, %options) = @_;
114              
115             defined $options{root}
116 13 50       55 or $class->my_croak( "option 'root' is required" );
117             ref $options{root}
118 13 50       78 and $class->my_croak( "option 'root' must be a string" );
119 13   33     490 $options{root} = abs_path($options{root}) || $options{root};
120              
121 13         63 my @extra = grep { !$static_options{$_} } keys %options;
  24         81  
122 13 50       47 $class->my_croak( "Unknown options @extra" )
123             if @extra;
124              
125 13   100     99 $options{buffer} ||= 4096;
126 13 50       108 $options{buffer} =~ /^(\d+)$/
127             or $class->my_croak( "option 'buffer' must be a positive integer" );
128              
129 13 100       47 if ($options{dir_index}) {
130 1   33     10 $options{view} ||= MVC::Neaf::View::TT->new;
131 1   50     5 $options{dir_template} ||= \$dir_template;
132             };
133              
134 13   100     112 $options{base_url} = canonize_path(($options{base_url} || '/'), 1);
135              
136             $options{description} = "Static content at $options{root}"
137 13 50       70 unless defined $options{description};
138              
139             # Don't store files twice
140 13         50 my $preload = delete $options{in_memory};
141 13         133 my $self = $class->SUPER::new(%options);
142              
143 13 100       50 $self->preload( %$preload )
144             if ($preload);
145              
146 13         89 return $self;
147             };
148              
149             =head2 serve_file( $path )
150              
151             Create a Neaf-compatible response using given path.
152             The response is like follows:
153              
154             {
155             -content => (file content),
156             -headers => (length, name etc),
157             -type => (content-type),
158             -continue => (serve the rest of the file, if needed),
159             };
160              
161             Will C if file is not there.
162              
163             This MAY be used to create more fine-grained control over static files.
164              
165             B. New options MAY be added.
166              
167             =cut
168              
169             sub serve_file {
170 14     14 1 10356 my ($self, $file) = @_;
171              
172 14         49 my $bufsize = $self->{buffer};
173 14         31 my $dir = $self->{root};
174 14         40 my $time = time;
175 14         24 my @header;
176              
177             # sanitize file path before caching
178 14         58 $file = canonize_path($file);
179              
180 14 100       68 if (my $data = $self->{cache_content}{$file}) {
181 6 50 66     36 if ($data->[1] and $data->[1] < $time) {
182 0         0 delete $self->{cache_content}{$file};
183             }
184             else {
185 6         36 return $data->[0];
186             };
187             };
188              
189             # don't let unsafe paths through
190 8 50       37 $file =~ m#/\.\./# and die 404;
191             $file =~ m#(^|/)\.# and die 404
192 8 50 50     50 unless $self->{allow_dots};
193              
194             # open file
195 8         27 my $xfile = join "", $dir, $file;
196              
197 8 100       173 if (-d $xfile) {
198             return $self->list_dir( $file )
199 1 50       10 if $self->{dir_index};
200 0         0 die 404; # Sic! Don't reveal directory structure
201             };
202 7         294 my $ok = open (my $fd, "<", "$xfile");
203 7 100       49 if (!$ok) {
204             # TODO 0.30 Warn
205 1         11 die 404;
206             };
207 6         19 binmode $fd;
208              
209 6         88 my $size = [stat $fd]->[7];
210 6         46 local $/ = \$bufsize;
211 6         244 my $buf = <$fd>;
212              
213             # determine type, fallback to extention
214 6         22 my $type;
215 6         140 $xfile =~ m#(?:^|/)([^\/]+?(?:\.(\w+))?)$#;
216 6 50       52 $type = $ExtType{lc $2} if defined $2; # TODO 0.40 unify with guess_type
217              
218 6         17 my $show_name = $1;
219 6         20 $show_name =~ s/[\"\x00-\x19\\]/_/g;
220              
221 6 100 66     91 my $disposition = ($type && $type =~ qr#^text|^image|javascript#)
222             ? ''
223             : "attachment; filename=\"$show_name\"";
224 6 100       25 push @header, content_disposition => $disposition
225             if $disposition;
226              
227             # return whole file if possible
228 6 100       21 if ($size < $bufsize) {
229 5         36 my $ret = { -content => $buf, -type => $type, -headers => \@header };
230 5 100       57 if ($self->{cache_ttl}) {
231 2         9 my $expires = $time + $self->{cache_ttl};
232 2         4 push @{ $ret->{-headers} }, expires => http_date( $expires );
  2         12  
233 2         12 $self->save_cache( $file, $expires, $ret );
234             };
235 5         114 return $ret;
236             };
237              
238             # If file is big, print header & first data chunk ASAP
239             # then do the rest via a second callback
240 1         6 push @header, content_length => $size;
241             my $continue = sub {
242 1     1   2 my $req = shift;
243              
244 1         5 local $/ = \$bufsize; # MUST do it again
245 1         6 while (<$fd>) {
246 43         117 $req->write($_);
247             };
248 1         8 $req->close;
249 1         7 };
250              
251 1         17 return { -content => $buf, -type => $type, -continue => $continue, -headers => \@header };
252             };
253              
254             =head2 list_dir( $path )
255              
256             Create a directory index reply.
257             Used by serve_file() if dir_index given.
258              
259             As of current, indices are not cached.
260              
261             =cut
262              
263             sub list_dir {
264 1     1 1 3 my ($self, $dir) = @_;
265              
266             # TODO 0.30 better error handling (404 or smth)
267 1 50       46 opendir( my $fd, "$self->{root}/$dir" )
268             or $self->my_croak( "Failed to locate directory at $dir: $!" );
269              
270 1         3 my @ret;
271 1         130 while (my $entry = readdir($fd)) {
272 125         772 $entry = decode_utf8($entry);
273             $entry =~ /^\./ and next
274 125 50 100     907 unless $self->{allow_dots};
275              
276 123         2341 my @stat = stat "$self->{root}/$dir/$entry";
277 123 100       1550 my $isdir = -d "$self->{root}/$dir/$entry" ? 1 : 0;
278              
279 123         487 push @ret, {
280             name => $entry,
281             dir => $isdir,
282             size => $stat[7],
283             lastmod => http_date( $stat[9] ),
284             };
285             };
286 1         35 closedir $fd;
287              
288 1 50       10 @ret = sort { $b->{dir} <=> $a->{dir} || $a->{name} cmp $b->{name} } @ret;
  714         1182  
289              
290 1         43 my $updir = dirname($dir);
291 1 50       5 $updir = '' if $updir eq '.';
292             return {
293             -view => $self->{view},
294             -template => $self->{dir_template},
295             list => \@ret,
296             date => http_date( time ),
297             path => $self->{base_url} . $dir,
298 1         5 updir => $self->{base_url} . $updir,
299             };
300             };
301              
302             =head2 preload( %files )
303              
304             Preload multiple in-memory files.
305              
306             Returns self.
307              
308             =cut
309              
310             sub preload {
311 7     7 1 28 my ($self, %files) = @_;
312              
313 7         25 foreach (keys %files) {
314 7         19 my $spec = $files{$_};
315             # guess order: png; image/png; filename.png; screw it - text
316 7   100     72 my $type = $ExtType{$spec->[1] || ''} || $spec->[1]
317             || $self->guess_type( $_, $spec->[0] ) || 'text/plain';
318              
319 7         35 $self->save_cache( $_, undef, {
320             -content => $spec->[0],
321             -type => $type,
322             } );
323             };
324              
325 7         33 return $self;
326             };
327              
328             =head2 one_file_handler()
329              
330             Returns a simple closure that accepts a L and
331             serves the requested path as is, relative to the X::Files objects's
332             root, or from cache.
333              
334             B. This is used internally by Neaf, name & meaning may change.
335              
336             =cut
337              
338             sub one_file_handler {
339 6     6 1 12 my $self = shift;
340              
341             return $self->{one_file} ||= sub {
342 3     3   6 my $req = shift;
343 3         10 return $self->serve_file( $req->path );
344 6   100     128 };
345             };
346              
347             =head2 save_cache( $name, $expires, \%data )
348              
349             Save data in cache.
350              
351             $name is canonized file name.
352              
353             $expires is unix timestamp. If undef, cache forever.
354              
355             =cut
356              
357             sub save_cache {
358 9     9 1 29 my ($self, $name, $expires, $content) = @_;
359              
360 9         24 $name = canonize_path( $name );
361 9         39 $self->{cache_content}{$name} = [ $content, $expires ];
362              
363 9         26 return $self;
364             };
365              
366             =head2 guess_type( $filename, $content )
367              
368             Returns file's MIME type. As of current, content is ignored,
369             and only file extention is considered.
370              
371             =cut
372              
373             sub guess_type {
374 5     5 1 13 my ($self, $name, $content) = @_;
375              
376 5 100       40 return unless $name =~ /\.([a-z0-9]{1,4})$/;
377 2         16 return $ExtType{lc $1};
378             };
379              
380              
381             =head2 make_route()
382              
383             Returns list of arguments suitable for Croute(...)>:
384              
385             =over
386              
387             =item * base url;
388              
389             =item * handler sub;
390              
391             =item * a hash of options: path_info_regex, cache_ttl, and description.
392              
393             =back
394              
395             =cut
396              
397             sub make_route {
398 4     4 1 10 my $self = shift;
399              
400 4 50       12 $self->my_croak("useless call in scalar/void context")
401             unless wantarray;
402              
403             my $handler = sub {
404 5     5   12 my $req = shift;
405              
406 5         26 my $file = $req->path_info();
407 5         22 return $self->serve_file( $file );
408 4         24 }; # end handler sub
409              
410             return (
411             $self->{base_url} => $handler,
412             method => ['GET', 'HEAD'],
413             path_info_regex => '.*',
414             cache_ttl => $self->{cache_ttl},
415             description => $self->{description},
416 4         62 );
417             };
418              
419             =head2 make_handler
420              
421             Returns a Neaf-compatible handler sub.
422              
423             B Use make_route instead. This dies.
424              
425             =cut
426              
427             sub make_handler {
428 0     0 1   my $self = shift;
429 0           $self->my_croak("DEPRECATED, use make_route() instead");
430             };
431              
432             =head1 LICENSE AND COPYRIGHT
433              
434             This module is part of L suite.
435              
436             Copyright 2016-2023 Konstantin S. Uvarin C.
437              
438             This program is free software; you can redistribute it and/or modify it
439             under the terms of either: the GNU General Public License as published
440             by the Free Software Foundation; or the Artistic License.
441              
442             See L for more information.
443              
444             =cut
445              
446             1;