File Coverage

blib/lib/PAGI/App/Directory.pm
Criterion Covered Total %
statement 99 115 86.0
branch 23 38 60.5
condition 10 22 45.4
subroutine 14 14 100.0
pod 0 2 0.0
total 146 191 76.4


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

Index of $escaped_path/

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