File Coverage

lib/PAGI/Middleware/Static.pm
Criterion Covered Total %
statement 157 172 91.2
branch 51 64 79.6
condition 36 56 64.2
subroutine 20 20 100.0
pod 1 1 100.0
total 265 313 84.6


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Static;
2              
3 1     1   210200 use strict;
  1         2  
  1         33  
4 1     1   2 use warnings;
  1         1  
  1         47  
5 1     1   383 use parent 'PAGI::Middleware';
  1         305  
  1         5  
6 1     1   49 use Future::AsyncAwait;
  1         1  
  1         4  
7 1     1   37 use File::Spec;
  1         1  
  1         18  
8 1     1   3 use Digest::MD5 'md5_hex';
  1         1  
  1         55  
9 1     1   4 use Fcntl ':mode';
  1         0  
  1         2933  
10              
11             =head1 NAME
12              
13             PAGI::Middleware::Static - Static file serving middleware
14              
15             =head1 SYNOPSIS
16              
17             use PAGI::Middleware::Builder;
18              
19             my $app = builder {
20             enable 'Static',
21             root => '/var/www/static',
22             path => qr{^/static/},
23             pass_through => 1;
24             $my_app;
25             };
26              
27             # Rewrite /static/... to /...
28             my $app = builder {
29             enable 'Static',
30             root => '/var/www/static',
31             path => sub {
32             my ($path) = @_;
33             return unless $path =~ m{^/static/};
34             $path =~ s{^/static}{/};
35             return $path; # Rewrite via return value
36             };
37             $my_app;
38             };
39              
40             # Legacy (in-place) rewrite - still supported
41             my $app = builder {
42             enable 'Static',
43             root => '/var/www/static',
44             path => sub { $_[0] =~ s{^/static}{/} };
45             $my_app;
46             };
47              
48             =head1 DESCRIPTION
49              
50             PAGI::Middleware::Static serves static files from a specified directory.
51             It includes path traversal protection, MIME type detection, ETag support
52             for caching, and Range request support for partial content.
53              
54             =head1 CONFIGURATION
55              
56             =over 4
57              
58             =item * root (required)
59              
60             The root directory to serve files from.
61              
62             =item * path (default: qr{^/})
63              
64             A regex or coderef to match request paths. Only matching paths are handled.
65              
66             When C is a coderef, it can also rewrite the request path:
67              
68             =over 4
69              
70             =item * Return a string to use as the rewritten path.
71              
72             =item * Return a true value (e.g., C<1>) to match without rewriting.
73              
74             =item * Return a false value to skip static handling.
75              
76             =back
77              
78             In-place mutation of C<$_[0]> is still supported for compatibility, but
79             returning the rewritten path is preferred.
80              
81             =item * pass_through (default: 0)
82              
83             If true, pass requests to inner app when file not found instead of returning 404.
84              
85             =item * index (default: ['index.html', 'index.htm'])
86              
87             Array of index file names to try for directory requests.
88              
89             =item * encoding (default: undef)
90              
91             If set, look for pre-compressed files with this extension (e.g., 'gz' for .gz files).
92              
93             =item * handle_ranges (default: 1)
94              
95             When enabled (default), the middleware processes Range request headers and returns
96             206 Partial Content responses. Set to 0 to ignore Range headers and always
97             return the full file.
98              
99             B
100              
101             When using L with a reverse proxy (Nginx, Apache),
102             you should disable range handling. The proxy will handle Range requests more
103             efficiently using its native sendfile implementation:
104              
105             my $app = builder {
106             enable 'XSendfile',
107             type => 'X-Accel-Redirect',
108             mapping => { '/var/www/files/' => '/protected/' };
109             enable 'Static',
110             root => '/var/www/files',
111             handle_ranges => 0; # Let proxy handle Range requests
112             $my_app;
113             };
114              
115             =back
116              
117             =cut
118              
119             # MIME type mapping
120             my %MIME_TYPES = (
121             html => 'text/html',
122             htm => 'text/html',
123             css => 'text/css',
124             js => 'application/javascript',
125             json => 'application/json',
126             xml => 'application/xml',
127             txt => 'text/plain',
128             csv => 'text/csv',
129              
130             # Images
131             png => 'image/png',
132             jpg => 'image/jpeg',
133             jpeg => 'image/jpeg',
134             gif => 'image/gif',
135             svg => 'image/svg+xml',
136             ico => 'image/x-icon',
137             webp => 'image/webp',
138              
139             # Fonts
140             woff => 'font/woff',
141             woff2 => 'font/woff2',
142             ttf => 'font/ttf',
143             otf => 'font/otf',
144             eot => 'application/vnd.ms-fontobject',
145              
146             # Documents
147             pdf => 'application/pdf',
148             zip => 'application/zip',
149             gz => 'application/gzip',
150             tar => 'application/x-tar',
151              
152             # Media
153             mp3 => 'audio/mpeg',
154             mp4 => 'video/mp4',
155             webm => 'video/webm',
156             ogg => 'audio/ogg',
157             wav => 'audio/wav',
158              
159             # Default
160             bin => 'application/octet-stream',
161             );
162              
163             sub _init {
164 22     22   28 my ($self, $config) = @_;
165              
166 22   50     57 $self->{root} = $config->{root} // die "Static middleware requires 'root' option";
167 22   66     111 $self->{path} = $config->{path} // qr{^/};
168 22   100     144 $self->{pass_through} = $config->{pass_through} // 0;
169 22   50     69 $self->{index} = $config->{index} // ['index.html', 'index.htm'];
170 22         47 $self->{encoding} = $config->{encoding};
171 22   100     57 $self->{handle_ranges} = $config->{handle_ranges} // 1;
172              
173             # Normalize root path
174 22         250 $self->{root} = File::Spec->rel2abs($self->{root});
175             }
176              
177             sub wrap {
178 22     22 1 129 my ($self, $app) = @_;
179              
180 23     23   434 return async sub {
181 23         32 my ($scope, $receive, $send) = @_;
182             # Only handle HTTP GET/HEAD requests
183 23 100 100     128 if ($scope->{type} ne 'http' || ($scope->{method} ne 'GET' && $scope->{method} ne 'HEAD')) {
      66        
184 1         3 await $app->($scope, $receive, $send);
185 1         93 return;
186             }
187              
188 22         31 my $path = $scope->{path};
189              
190             # Check if path matches our pattern
191 22         25 my $path_match = $self->{path};
192 22 100       43 if (ref($path_match) eq 'Regexp') {
    50          
193 20 50       102 unless ($path =~ $path_match) {
194 0         0 await $app->($scope, $receive, $send);
195 0         0 return;
196             }
197             } elsif (ref($path_match) eq 'CODE') {
198 2         5 my $result = $path_match->($path);
199 2 50       14 unless ($result) {
200 0         0 await $app->($scope, $receive, $send);
201 0         0 return;
202             }
203 2 100 33     12 if (defined $result && !ref($result) && $result ne '1') {
      66        
204 1         1 $path = $result;
205             }
206             }
207              
208             # Normalize rewritten paths to start with /
209 22 100 33     93 if (defined $path && $path ne '' && $path !~ m{^/}) {
      66        
210 1         1 $path = '/' . $path;
211             }
212              
213             # Build file path
214 22         40 my $file_path = $self->_resolve_path($path);
215              
216             # Check for path traversal
217 22 100 66     53 unless ($file_path && $self->_is_safe_path($file_path)) {
218 1         3 await $self->_send_error($send, 403, 'Forbidden');
219 1         114 return;
220             }
221              
222             # Check if file exists
223 21 100       690 unless (-e $file_path) {
224 4 100       10 if ($self->{pass_through}) {
225 1         3 await $app->($scope, $receive, $send);
226 1         139 return;
227             }
228 3         8 await $self->_send_error($send, 404, 'Not Found');
229 3         170 return;
230             }
231              
232             # Handle directory with index files
233 17 100       135 if (-d $file_path) {
234 1         4 my $index_file = $self->_find_index($file_path);
235 1 50       3 if ($index_file) {
236 1         11 $file_path = $index_file;
237             } else {
238 0 0       0 if ($self->{pass_through}) {
239 0         0 await $app->($scope, $receive, $send);
240 0         0 return;
241             }
242 0         0 await $self->_send_error($send, 404, 'Not Found');
243 0         0 return;
244             }
245             }
246              
247             # Get file stats
248 17         156 my @stat = stat($file_path);
249 17 50       31 unless (@stat) {
250 0         0 await $self->_send_error($send, 500, 'Cannot stat file');
251 0         0 return;
252             }
253              
254 17         18 my $size = $stat[7];
255 17         13 my $mtime = $stat[9];
256              
257             # Generate ETag
258 17         37 my $etag = $self->_generate_etag($file_path, $size, $mtime);
259              
260             # Check If-None-Match for 304 response
261 17         28 my $if_none_match = $self->_get_header($scope, 'if-none-match');
262 17 100 66     33 if ($if_none_match && $if_none_match eq $etag) {
263 1         6 await $send->({
264             type => 'http.response.start',
265             status => 304,
266             headers => [
267             ['etag', $etag],
268             ],
269             });
270 1         34 await $send->({
271             type => 'http.response.body',
272             body => '',
273             more => 0,
274             });
275 1         25 return;
276             }
277              
278             # Get MIME type
279 16         24 my $content_type = $self->_get_mime_type($file_path);
280              
281             # Check for Range request (only if handle_ranges is enabled)
282 16 100       61 my $range_header = $self->{handle_ranges} ? $self->_get_header($scope, 'range') : undef;
283 16         27 my ($start, $end, $is_range) = $self->_parse_range($range_header, $size);
284              
285 16 100 100     34 if ($is_range && !defined $start) {
286             # Invalid range
287 1         3 await $self->_send_error($send, 416, 'Range Not Satisfiable');
288 1         49 return;
289             }
290              
291             # Build headers
292 15         46 my @headers = (
293             ['content-type', $content_type],
294             ['etag', $etag],
295             ['last-modified', $self->_format_http_date($mtime)],
296             ['accept-ranges', 'bytes'],
297             );
298              
299 15         20 my $status;
300             my $body_size;
301              
302 15 100       20 if ($is_range) {
303 4         6 $status = 206;
304 4         5 $body_size = $end - $start + 1;
305 4         15 push @headers, ['content-range', "bytes $start-$end/$size"];
306 4         8 push @headers, ['content-length', $body_size];
307             } else {
308 11         12 $status = 200;
309 11         11 $body_size = $size;
310 11         20 push @headers, ['content-length', $size];
311             }
312              
313             # Send response start
314 15         79 await $send->({
315             type => 'http.response.start',
316             status => $status,
317             headers => \@headers,
318             });
319              
320             # For HEAD requests, don't send body
321 15 100       835 if ($scope->{method} eq 'HEAD') {
322 1         4 await $send->({
323             type => 'http.response.body',
324             body => '',
325             more => 0,
326             });
327 1         31 return;
328             }
329              
330             # Use file response for efficient streaming (sendfile or worker pool)
331             # This also enables XSendfile middleware to intercept the response
332 14 100       21 if ($is_range) {
333 4         16 await $send->({
334             type => 'http.response.body',
335             file => $file_path,
336             offset => $start,
337             length => $body_size,
338             });
339             }
340             else {
341 10         28 await $send->({
342             type => 'http.response.body',
343             file => $file_path,
344             });
345             }
346 22         142 };
347             }
348              
349             sub _resolve_path {
350 22     22   28 my ($self, $url_path) = @_;
351              
352             # Path is already URL-decoded by the server, so no decoding here
353 22         23 my $decoded = $url_path;
354              
355             # Remove query string
356 22         28 $decoded =~ s/\?.*//;
357              
358             # Combine with root (use manual concat to preserve .. for security check)
359 22         27 my $root = $self->{root};
360 22         30 $root =~ s{/$}{}; # Remove trailing slash from root
361 22         50 return $root . $decoded;
362             }
363              
364             sub _is_safe_path {
365 22     22   26 my ($self, $file_path) = @_;
366              
367 22         27 my $root = $self->{root};
368              
369             # Manually resolve the path to handle .. without requiring file to exist
370 22         43 my $abs_path = $self->_resolve_dots($file_path);
371 22 50       31 return 0 unless defined $abs_path;
372              
373             # Normalize both paths
374 22         173 $abs_path =~ s{/+}{/}g;
375 22         100 $root =~ s{/+}{/}g;
376 22         36 $root =~ s{/$}{};
377 22         22 $abs_path =~ s{/$}{};
378              
379             # Path must start with root
380 22         169 return $abs_path =~ m{^\Q$root\E(?:/|$)};
381             }
382              
383             sub _resolve_dots {
384 22     22   21 my ($self, $path) = @_;
385              
386             # Split path into components
387 22         75 my @parts = split m{/}, $path;
388 22         14 my @resolved;
389              
390 22         32 for my $part (@parts) {
391 186 100 66     314 if ($part eq '' || $part eq '.') {
    100          
392             # Skip empty and current dir
393 23         32 next;
394             } elsif ($part eq '..') {
395             # Go up one directory
396 3 50       5 if (@resolved) {
397 3         4 pop @resolved;
398             }
399             # If we can't go up, the path is invalid (would escape root)
400             } else {
401 160         169 push @resolved, $part;
402             }
403             }
404              
405             # Reconstruct absolute path
406 22         83 return '/' . join('/', @resolved);
407             }
408              
409             sub _find_index {
410 1     1   2 my ($self, $dir_path) = @_;
411              
412 1         2 for my $index (@{$self->{index}}) {
  1         3  
413 1         15 my $index_path = File::Spec->catfile($dir_path, $index);
414 1 50       27 return $index_path if -f $index_path;
415             }
416 0         0 return;
417             }
418              
419             sub _generate_etag {
420 17     17   30 my ($self, $file_path, $size, $mtime) = @_;
421              
422 17         28 my $data = "$file_path:$size:$mtime";
423 17         75 return '"' . md5_hex($data) . '"';
424             }
425              
426             sub _get_mime_type {
427 16     16   18 my ($self, $file_path) = @_;
428              
429 16         101 my ($ext) = $file_path =~ /\.([^.]+)$/;
430 16   50     29 $ext = lc($ext // '');
431 16   50     45 return $MIME_TYPES{$ext} // 'application/octet-stream';
432             }
433              
434             sub _get_header {
435 32     32   46 my ($self, $scope, $name) = @_;
436              
437 32         35 $name = lc($name);
438 32   50     25 for my $h (@{$scope->{headers} // []}) {
  32         67  
439 12 100       32 return $h->[1] if lc($h->[0]) eq $name;
440             }
441 26         33 return;
442             }
443              
444             sub _parse_range {
445 16     16   18 my ($self, $range_header, $size) = @_;
446              
447 16 100       36 return (undef, undef, 0) unless $range_header;
448              
449             # Parse "bytes=start-end" format
450 5 50       23 if ($range_header =~ /^bytes=(\d*)-(\d*)$/) {
451 5         18 my ($start, $end) = ($1, $2);
452              
453 5 100 66     29 if ($start eq '' && $end ne '') {
    50 33        
454             # Suffix range: last N bytes
455 1         2 $start = $size - $end;
456 1         2 $end = $size - 1;
457             } elsif ($start ne '' && $end eq '') {
458             # From start to end
459 0         0 $start = int($start);
460 0         0 $end = $size - 1;
461             } else {
462 4         7 $start = int($start);
463 4         6 $end = int($end);
464             }
465              
466             # Validate range
467 5 100 66     17 return (undef, undef, 1) if $start > $end || $start >= $size;
468              
469 4 50       6 $end = $size - 1 if $end >= $size;
470              
471 4         11 return ($start, $end, 1);
472             }
473              
474 0         0 return (undef, undef, 0);
475             }
476              
477             sub _format_http_date {
478 15     15   22 my ($self, $epoch) = @_;
479              
480 15         35 my @days = qw(Sun Mon Tue Wed Thu Fri Sat);
481 15         35 my @months = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
482 15         39 my @t = gmtime($epoch);
483 15         124 return sprintf("%s, %02d %s %04d %02d:%02d:%02d GMT",
484             $days[$t[6]], $t[3], $months[$t[4]], $t[5] + 1900,
485             $t[2], $t[1], $t[0]);
486             }
487              
488 5     5   7 async sub _send_error {
489 5         11 my ($self, $send, $status, $message) = @_;
490              
491 5         26 await $send->({
492             type => 'http.response.start',
493             status => $status,
494             headers => [
495             ['content-type', 'text/plain'],
496             ['content-length', length($message)],
497             ],
498             });
499 5         247 await $send->({
500             type => 'http.response.body',
501             body => $message,
502             more => 0,
503             });
504             }
505              
506             1;
507              
508             __END__