File Coverage

blib/lib/PAGI/App/Directory.pm
Criterion Covered Total %
statement 109 115 94.7
branch 34 38 89.4
condition 12 22 54.5
subroutine 14 14 100.0
pod 0 2 0.0
total 169 191 88.4


line stmt bran cond sub pod time code
1             package PAGI::App::Directory;
2              
3 4     4   448221 use strict;
  4         6  
  4         206  
4 4     4   40 use warnings;
  4         5  
  4         396  
5 4     4   792 use Future::AsyncAwait;
  4         27040  
  4         41  
6 4     4   238 use parent 'PAGI::App::File';
  4         6  
  4         95  
7 4     4   2069 use JSON::MaybeXS ();
  4         35897  
  4         184  
8 4     4   30 use File::Spec;
  4         6  
  4         135  
9 4     4   19 use Cwd qw(realpath);
  4         5  
  4         8116  
10              
11             =head1 NAME
12              
13             PAGI::App::Directory - Serve files with directory listing
14              
15             =head1 SYNOPSIS
16              
17             use PAGI::App::Directory;
18              
19             my $app = PAGI::App::Directory->new(
20             root => '/var/www/files',
21             )->to_app;
22              
23             =cut
24              
25             sub new {
26 14     14 0 15466 my ($class, %args) = @_;
27              
28 14         68 my $self = $class->SUPER::new(%args);
29 14   50     79 $self->{show_hidden} = $args{show_hidden} // 0;
30             # Cache realpath of root for symlink escape detection
31 14   33     370 $self->{real_root} = realpath($self->{root}) // $self->{root};
32 14         51 return $self;
33             }
34              
35             # HTML escape to prevent XSS
36             sub _html_escape {
37 18     18   12526 my $str = shift;
38 18 100       87 return '' unless defined $str;
39 17         28 $str =~ s/&/&/g;
40 17         23 $str =~ s/
41 17         23 $str =~ s/>/>/g;
42 17         22 $str =~ s/"/"/g;
43 17         20 $str =~ s/'/'/g;
44 17         44 return $str;
45             }
46              
47             # URL encode for href attributes
48             sub _url_encode {
49 8     8   2037 my $str = shift;
50 8 100       14 return '' unless defined $str;
51 7         23 $str =~ s/([^A-Za-z0-9\-_.~\/])/sprintf("%%%02X", ord($1))/ge;
  4         16  
52 7         18 return $str;
53             }
54              
55             sub to_app {
56 14     14 0 38 my ($self) = @_;
57              
58 14         45 my $parent_app = $self->SUPER::to_app();
59 14         43 my $root = $self->{root};
60 14         21 my $real_root = $self->{real_root};
61              
62 9     9   92 return async sub {
63 9         16 my ($scope, $receive, $send) = @_;
64 9 100       52 die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';
65              
66 6   50     15 my $path = $scope->{path} // '/';
67 6         50 $path =~ s{^/+}{};
68 6         55 my $dir_path = File::Spec->catdir($root, $path);
69              
70             # Symlink escape check: ensure resolved path is within root
71 6         176 my $real_dir = realpath($dir_path);
72 6 100 66     34 if (!$real_dir || index($real_dir, $real_root) != 0) {
73 2         11 await $self->_send_error($send, 403, 'Forbidden');
74 2         97 return;
75             }
76              
77             # If it's a directory without index file, show listing
78 4 100       29 if (-d $dir_path) {
79 3         5 my $has_index = 0;
80 3         3 for my $index (@{$self->{index}}) {
  3         9  
81 6 50       200 if (-f File::Spec->catfile($dir_path, $index)) {
82 0         0 $has_index = 1;
83 0         0 last;
84             }
85             }
86              
87 3 50       10 unless ($has_index) {
88 3         16 await $self->_send_listing($send, $scope, $dir_path, $path);
89 3         128 return;
90             }
91             }
92              
93             # Fall back to parent file serving
94 1         4 await $parent_app->($scope, $receive, $send);
95 14         61 };
96             }
97              
98 3     3   3 async sub _send_listing {
99 3         8 my ($self, $send, $scope, $dir_path, $rel_path) = @_;
100              
101 3 50       111 opendir my $dh, $dir_path or do {
102 0         0 await $self->_send_error($send, 403, 'Forbidden');
103 0         0 return;
104             };
105              
106 3         6 my @entries;
107 3         65 while (my $entry = readdir $dh) {
108 13 100       28 next if $entry eq '.';
109 10 100 66     35 next if !$self->{show_hidden} && $entry =~ /^\./;
110              
111 7         46 my $full_path = File::Spec->catfile($dir_path, $entry);
112 7         63 my @stat = stat($full_path);
113 7 100 50     143 push @entries, {
      50        
114             name => $entry,
115             is_dir => -d $full_path ? 1 : 0,
116             size => $stat[7] // 0,
117             mtime => $stat[9] // 0,
118             };
119             }
120 3         29 closedir $dh;
121              
122             # Sort directories first, then by name
123 3 50       13 @entries = sort { $b->{is_dir} <=> $a->{is_dir} || $a->{name} cmp $b->{name} } @entries;
  6         19  
124              
125             # Check Accept header for JSON
126 3   100     19 my $accept = $self->_get_header($scope, 'accept') // '';
127 3 100       10 if ($accept =~ m{application/json}) {
128 1         60 my $json = JSON::MaybeXS::encode_json(\@entries);
129 1         8 await $send->({
130             type => 'http.response.start',
131             status => 200,
132             headers => [['content-type', 'application/json'], ['content-length', length($json)]],
133             });
134 1         41 await $send->({ type => 'http.response.body', body => $json, more => 0 });
135 1         32 return;
136             }
137              
138             # HTML listing
139 2 100       8 my $base_path = $rel_path eq '' ? '/' : "/$rel_path";
140 2         10 $base_path =~ s{/+$}{};
141              
142             # Escape base_path for safe HTML output
143 2         8 my $escaped_path = _html_escape($base_path);
144              
145 2         5 my $html = "Index of $escaped_path/";
146 2         3 $html .= '';
149 2         5 $html .= "

Index of $escaped_path/

"; '; };
NameSize
150              
151 2 100       7 if ($rel_path ne '') {
152 1         2 $html .= '
..-
153             }
154              
155 2         5 for my $entry (@entries) {
156 4         9 my $name = $entry->{name};
157 4 100       8 my $display = $entry->{is_dir} ? "$name/" : $name;
158 4 100       10 my $href = "$name" . ($entry->{is_dir} ? '/' : '');
159 4 100       11 my $size = $entry->{is_dir} ? '-' : _format_size($entry->{size});
160              
161             # Escape all user-controlled values to prevent XSS
162 4         9 my $escaped_display = _html_escape($display);
163 4         10 my $escaped_href = _html_escape(_url_encode($href));
164 4         9 $html .= qq{
$escaped_display$size
165             }
166              
167 2         4 $html .= '
';
168              
169 2         15 await $send->({
170             type => 'http.response.start',
171             status => 200,
172             headers => [['content-type', 'text/html'], ['content-length', length($html)]],
173             });
174 2         85 await $send->({ type => 'http.response.body', body => $html, more => 0 });
175             }
176              
177             sub _format_size {
178 3     3   4 my $size = shift;
179 3 100       8 return '0' if $size == 0;
180 2         5 my @units = qw(B KB MB GB);
181 2         1 my $i = 0;
182 2   33     50 while ($size >= 1024 && $i < $#units) {
183 0         0 $size /= 1024;
184 0         0 $i++;
185             }
186 2         29 return sprintf("%.1f %s", $size, $units[$i]);
187             }
188              
189             1;
190              
191             __END__