+# PATH_INFO is decoded, and we want the undecoded original
+my %path_re_cache;
+sub path_info_raw ($) {
+ my ($env) = @_;
+ my $sn = $env->{SCRIPT_NAME};
+ my $re = $path_re_cache{$sn} ||= do {
+ $sn = '/'.$sn unless index($sn, '/') == 0;
+ $sn =~ s!/\z!!;
+ qr!\A(?:https?://[^/]+)?\Q$sn\E(/[^\?\#]+)!;
+ };
+ $env->{REQUEST_URI} =~ $re ? $1 : $env->{PATH_INFO};
+}
+
+sub redirect_slash ($) {
+ my ($env) = @_;
+ my $url = $env->{'psgi.url_scheme'} . '://';
+ my $host_port = $env->{HTTP_HOST} //
+ "$env->{SERVER_NAME}:$env->{SERVER_PORT}";
+ $url .= $host_port . path_info_raw($env) . '/';
+ my $body = "Redirecting to $url\n";
+ [ 302, [ qw(Content-Type text/plain), 'Location', $url,
+ 'Content-Length', length($body) ], [ $body ] ]
+}
+
+sub human_size ($) {
+ my ($size) = @_;
+ my $suffix = '';
+ for my $s (qw(K M G T P)) {
+ last if $size < 1024;
+ $size /= 1024;
+ if ($size <= 1024) {
+ $suffix = $s;
+ last;
+ }
+ }
+ sprintf('%lu', $size).$suffix;
+}
+
+# by default, this returns "index.html" if it exists for a given directory
+# It'll generate a directory listing, (autoindex).
+# May be disabled by setting autoindex => 0
+sub dir_response ($$$) {
+ my ($self, $env, $fs_path) = @_;
+ if (my $index = $self->{'index'}) { # serve index.html or similar
+ for my $html (@$index) {
+ my $p = $fs_path . $html;
+ my $res = response($env, [], $p);
+ return $res if $res->[0] != 404;
+ }
+ }
+ return r(404) unless $self->{autoindex};
+ opendir(my $dh, $fs_path) or do {
+ return r(404) if ($! == ENOENT || $! == ENOTDIR);
+ return r(403) if $! == EACCES;
+ return r(500);
+ };
+ my @entries = grep(!/\A\./, readdir($dh));
+ $dh = undef;
+ my (%dirs, %other, %want_gz);
+ my $path_info = $env->{PATH_INFO};
+ push @entries, '..' if $path_info ne '/';
+ for my $base (@entries) {
+ my $href = ascii_html(uri_escape_utf8($base));
+ my $name = ascii_html($base);
+ my @st = stat($fs_path . $base) or next; # unlikely
+ my ($gzipped, $uncompressed, $hsize);
+ my $entry = '';
+ my $mtime = $st[9];
+ if (-d _) {
+ $href .= '/';
+ $name .= '/';
+ $hsize = '-';
+ $dirs{"$base\0$mtime"} = \$entry;
+ } elsif (-f _) {
+ $other{"$base\0$mtime"} = \$entry;
+ if ($base !~ /\.gz\z/i) {
+ $want_gz{"$base.gz\0$mtime"} = undef;
+ }
+ $hsize = human_size($st[7]);
+ } else {
+ next;
+ }
+ # 54 = 80 - (SP length(strftime(%Y-%m-%d %k:%M)) SP human_size)
+ $hsize = sprintf('% 8s', $hsize);
+ my $pad = 54 - length($name);
+ $pad = 1 if $pad <= 0;
+ $entry .= qq(<a\nhref="$href">$name</a>) . (' ' x $pad);
+ $mtime = strftime('%Y-%m-%d %k:%M', gmtime($mtime));
+ $entry .= $mtime . $hsize;
+ }
+
+ # filter out '.gz' files as long as the mtime matches the
+ # uncompressed version
+ delete(@other{keys %want_gz});
+ @entries = ((map { ${$dirs{$_}} } sort keys %dirs),
+ (map { ${$other{$_}} } sort keys %other));
+
+ my $path_info_html = ascii_html($path_info);
+ my $h = [qw(Content-Type text/html Content-Length), undef];
+ my $gzf = gzf_maybe($h, $env);
+ $gzf->zmore("<html><head><title>Index of $path_info_html</title>" .
+ ${$self->{style}} .
+ "</head><body><pre>Index of $path_info_html</pre><hr><pre>\n");
+ $gzf->zmore(join("\n", @entries));
+ my $out = $gzf->zflush("</pre><hr></body></html>\n");
+ $h->[3] = length($out);
+ [ 200, $h, [ $out ] ]
+}
+
+sub call { # PSGI app endpoint
+ my ($self, $env) = @_;
+ return r(405) if $env->{REQUEST_METHOD} !~ /\A(?:GET|HEAD)\z/;
+ my $path_info = $env->{PATH_INFO};
+ return r(403) if index($path_info, "\0") >= 0;
+ my (@parts) = split(m!/+!, $path_info, -1);
+ return r(403) if grep(/\A(?:\.\.)\z/, @parts) || $parts[0] ne '';
+
+ my $fs_path = join('/', $self->{docroot}, @parts);
+ return dir_response($self, $env, $fs_path) if $parts[-1] eq '';
+
+ my $res = response($env, [], $fs_path);
+ $res->[0] == 404 && -d $fs_path ? redirect_slash($env) : $res;
+}