]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/WwwStatic.pm
wwwstatic: support gzipped directory listings
[public-inbox.git] / lib / PublicInbox / WwwStatic.pm
1 # Copyright (C) 2016-2020 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # This package can either be a PSGI response body for a static file
5 # OR a standalone PSGI app which returns the above PSGI response body
6 # (or an HTML directory listing).
7 #
8 # It encapsulates the "autoindex", "index", and "gzip_static"
9 # functionality of nginx.
10 package PublicInbox::WwwStatic;
11 use strict;
12 use parent qw(Exporter);
13 use bytes ();
14 use Fcntl qw(SEEK_SET O_RDONLY O_NONBLOCK);
15 use POSIX qw(strftime);
16 use HTTP::Date qw(time2str);
17 use HTTP::Status qw(status_message);
18 use Errno qw(EACCES ENOTDIR ENOENT);
19 use URI::Escape qw(uri_escape_utf8);
20 use PublicInbox::NoopFilter;
21 use PublicInbox::GzipFilter qw(gzf_maybe);
22 use PublicInbox::Hval qw(ascii_html);
23 use Plack::MIME;
24 our @EXPORT_OK = qw(@NO_CACHE r path_info_raw);
25
26 our @NO_CACHE = ('Expires', 'Fri, 01 Jan 1980 00:00:00 GMT',
27                 'Pragma', 'no-cache',
28                 'Cache-Control', 'no-cache, max-age=0, must-revalidate');
29
30 our $STYLE = <<'EOF';
31 <style>
32 @media screen {
33         *{background:#000;color:#ccc}
34         a{color:#69f;text-decoration:none}
35         a:visited{color:#96f}
36 }
37 @media screen AND (prefers-color-scheme:light) {
38         *{background:#fff;color:#333}
39         a{color:#00f;text-decoration:none}
40         a:visited{color:#808}
41 }
42 </style>
43 EOF
44
45 $STYLE =~ s/^\s*//gm;
46 $STYLE =~ tr/\n//d;
47
48 sub r ($;$) {
49         my ($code, $msg) = @_;
50         $msg ||= status_message($code);
51         [ $code, [ qw(Content-Type text/plain), 'Content-Length', length($msg),
52                 @NO_CACHE ],
53           [ $msg ] ]
54 }
55
56 sub getline_response ($$$$$) {
57         my ($env, $in, $off, $len, $path) = @_;
58         my $r = bless {}, __PACKAGE__;
59         if ($env->{'pi-httpd.async'}) { # public-inbox-httpd-only mode
60                 $env->{'psgix.no-compress'} = 1; # do not chunk response
61                 %$r = ( bypass => [$in, $off, $len, $env->{'psgix.io'}] );
62         } else {
63                 %$r = ( in => $in, off => $off, len => $len, path => $path );
64         }
65         $r;
66 }
67
68 sub setup_range {
69         my ($env, $in, $h, $beg, $end, $size) = @_;
70         my $code = 200;
71         my $len = $size;
72         if ($beg eq '') {
73                 if ($end ne '') { # "bytes=-$end" => last N bytes
74                         $beg = $size - $end;
75                         $beg = 0 if $beg < 0;
76                         $end = $size - 1;
77                         $code = 206;
78                 } else {
79                         $code = 416;
80                 }
81         } else {
82                 if ($beg > $size) {
83                         $code = 416;
84                 } elsif ($end eq '' || $end >= $size) {
85                         $end = $size - 1;
86                         $code = 206;
87                 } elsif ($end < $size) {
88                         $code = 206;
89                 } else {
90                         $code = 416;
91                 }
92         }
93         if ($code == 206) {
94                 $len = $end - $beg + 1;
95                 if ($len <= 0) {
96                         $code = 416;
97                 } else {
98                         push @$h, qw(Accept-Ranges bytes Content-Range);
99                         push @$h, "bytes $beg-$end/$size";
100
101                         # FIXME: Plack::Middleware::Deflater bug?
102                         $env->{'psgix.no-compress'} = 1;
103                 }
104         }
105         if ($code == 416) {
106                 push @$h, 'Content-Range', "bytes */$size";
107                 return [ 416, $h, [] ];
108         }
109         ($code, $beg, $len);
110 }
111
112 # returns a PSGI arrayref response iff .gz and non-.gz mtimes match
113 sub try_gzip_static ($$$$) {
114         my ($env, $h, $path, $type) = @_;
115         return unless ($env->{HTTP_ACCEPT_ENCODING} // '') =~ /\bgzip\b/i;
116         my $mtime;
117         return unless -f $path && defined(($mtime = (stat(_))[9]));
118         my $gz = "$path.gz";
119         return unless -f $gz && (stat(_))[9] == $mtime;
120         my $res = response($env, $h, $gz, $type);
121         return if ($res->[0] > 300 || $res->[0] < 200);
122         push @{$res->[1]}, qw(Cache-Control no-transform
123                                 Content-Encoding gzip
124                                 Vary Accept-Encoding);
125         $res;
126 }
127
128 sub response ($$$;$) {
129         my ($env, $h, $path, $type) = @_;
130         $type //= Plack::MIME->mime_type($path) // 'application/octet-stream';
131         if ($path !~ /\.gz\z/i) {
132                 if (my $res = try_gzip_static($env, $h, $path, $type)) {
133                         return $res;
134                 }
135         }
136
137         my $in;
138         if ($env->{REQUEST_METHOD} eq 'HEAD') {
139                 return r(404) unless -f $path && -r _; # in case it's a FIFO :P
140         } else { # GET, callers should've already filtered out other methods
141                 if (!sysopen($in, $path, O_RDONLY|O_NONBLOCK)) {
142                         return r(404) if $! == ENOENT || $! == ENOTDIR;
143                         return r(403) if $! == EACCES;
144                         return r(500);
145                 }
146                 return r(404) unless -f $in;
147         }
148         my $size = -s _; # bare "_" reuses "struct stat" from "-f" above
149         my $mtime = time2str((stat(_))[9]);
150
151         if (my $ims = $env->{HTTP_IF_MODIFIED_SINCE}) {
152                 return [ 304, [], [] ] if $mtime eq $ims;
153         }
154
155         my $len = $size;
156         my $code = 200;
157         push @$h, 'Content-Type', $type;
158         my $off = 0;
159         if (($env->{HTTP_RANGE} || '') =~ /\bbytes=([0-9]*)-([0-9]*)\z/) {
160                 ($code, $off, $len) = setup_range($env, $in, $h, $1, $2, $size);
161                 return $code if ref($code);
162         }
163         push @$h, 'Content-Length', $len, 'Last-Modified', $mtime;
164         [ $code, $h, $in ? getline_response($env, $in, $off, $len, $path) : [] ]
165 }
166
167 # called by PSGI servers on each response chunk:
168 sub getline {
169         my ($self) = @_;
170
171         # avoid buffering, by becoming the buffer! (public-inbox-httpd)
172         if (my $tmpio = delete $self->{bypass}) {
173                 my $http = pop @$tmpio; # PublicInbox::HTTP
174                 push @{$http->{wbuf}}, $tmpio; # [ $in, $off, $len ]
175                 $http->flush_write;
176                 return; # undef, EOF
177         }
178
179         # generic PSGI runs this:
180         my $len = $self->{len} or return; # undef, tells server we're done
181         my $n = 8192;
182         $n = $len if $len < $n;
183         sysseek($self->{in}, $self->{off}, SEEK_SET) or
184                         die "sysseek ($self->{path}): $!";
185         my $r = sysread($self->{in}, my $buf, $n);
186         if (defined $r && $r > 0) { # success!
187                 $self->{len} = $len - $r;
188                 $self->{off} += $r;
189                 return $buf;
190         }
191         my $m = defined $r ? "EOF with $len bytes left" : "read error: $!";
192         die "$self->{path} $m, dropping client socket\n";
193 }
194
195 sub close {} # noop, called by PSGI server, just let everything go out-of-scope
196
197 # OO interface for use as a Plack app
198 sub new {
199         my ($class, %opt) = @_;
200         my $index = $opt{'index'} // [ 'index.html' ];
201         $index = [ $index ] if defined($index) && ref($index) ne 'ARRAY';
202         $index = undef if scalar(@$index) == 0;
203         my $style = $opt{style};
204         if (defined $style) {
205                 $style = \$style unless ref($style);
206         }
207         my $docroot = $opt{docroot};
208         die "`docroot' not set" unless defined($docroot) && $docroot ne '';
209         bless {
210                 docroot => $docroot,
211                 index => $index,
212                 autoindex => $opt{autoindex},
213                 style => $style // \$STYLE,
214         }, $class;
215 }
216
217 # PATH_INFO is decoded, and we want the undecoded original
218 my %path_re_cache;
219 sub path_info_raw ($) {
220         my ($env) = @_;
221         my $sn = $env->{SCRIPT_NAME};
222         my $re = $path_re_cache{$sn} ||= do {
223                 $sn = '/'.$sn unless index($sn, '/') == 0;
224                 $sn =~ s!/\z!!;
225                 qr!\A(?:https?://[^/]+)?\Q$sn\E(/[^\?\#]+)!;
226         };
227         $env->{REQUEST_URI} =~ $re ? $1 : $env->{PATH_INFO};
228 }
229
230 sub redirect_slash ($) {
231         my ($env) = @_;
232         my $url = $env->{'psgi.url_scheme'} . '://';
233         my $host_port = $env->{HTTP_HOST} //
234                 "$env->{SERVER_NAME}:$env->{SERVER_PORT}";
235         $url .= $host_port . path_info_raw($env) . '/';
236         my $body = "Redirecting to $url\n";
237         [ 302, [ qw(Content-Type text/plain), 'Location', $url,
238                 'Content-Length', length($body) ], [ $body ] ]
239 }
240
241 sub human_size ($) {
242         my ($size) = @_;
243         my $suffix = '';
244         for my $s (qw(K M G T P)) {
245                 last if $size < 1024;
246                 $size /= 1024;
247                 if ($size <= 1024) {
248                         $suffix = $s;
249                         last;
250                 }
251         }
252         sprintf('%lu', $size).$suffix;
253 }
254
255 # by default, this returns "index.html" if it exists for a given directory
256 # It'll generate a directory listing, (autoindex).
257 # May be disabled by setting autoindex => 0
258 sub dir_response ($$$) {
259         my ($self, $env, $fs_path) = @_;
260         if (my $index = $self->{'index'}) { # serve index.html or similar
261                 for my $html (@$index) {
262                         my $p = $fs_path . $html;
263                         my $res = response($env, [], $p);
264                         return $res if $res->[0] != 404;
265                 }
266         }
267         return r(404) unless $self->{autoindex};
268         opendir(my $dh, $fs_path) or do {
269                 return r(404) if ($! == ENOENT || $! == ENOTDIR);
270                 return r(403) if $! == EACCES;
271                 return r(500);
272         };
273         my @entries = grep(!/\A\./, readdir($dh));
274         $dh = undef;
275         my (%dirs, %other, %want_gz);
276         my $path_info = $env->{PATH_INFO};
277         push @entries, '..' if $path_info ne '/';
278         for my $base (@entries) {
279                 my $href = ascii_html(uri_escape_utf8($base));
280                 my $name = ascii_html($base);
281                 my @st = stat($fs_path . $base) or next; # unlikely
282                 my ($gzipped, $uncompressed, $hsize);
283                 my $entry = '';
284                 my $mtime = $st[9];
285                 if (-d _) {
286                         $href .= '/';
287                         $name .= '/';
288                         $hsize = '-';
289                         $dirs{"$base\0$mtime"} = \$entry;
290                 } elsif (-f _) {
291                         $other{"$base\0$mtime"} = \$entry;
292                         if ($base !~ /\.gz\z/i) {
293                                 $want_gz{"$base.gz\0$mtime"} = undef;
294                         }
295                         $hsize = human_size($st[7]);
296                 } else {
297                         next;
298                 }
299                 # 54 = 80 - (SP length(strftime(%Y-%m-%d %k:%M)) SP human_size)
300                 $hsize = sprintf('% 8s', $hsize);
301                 my $pad = 54 - length($name);
302                 $pad = 1 if $pad <= 0;
303                 $entry .= qq(<a\nhref="$href">$name</a>) . (' ' x $pad);
304                 $mtime = strftime('%Y-%m-%d %k:%M', gmtime($mtime));
305                 $entry .= $mtime . $hsize;
306         }
307
308         # filter out '.gz' files as long as the mtime matches the
309         # uncompressed version
310         delete(@other{keys %want_gz});
311         @entries = ((map { ${$dirs{$_}} } sort keys %dirs),
312                         (map { ${$other{$_}} } sort keys %other));
313
314         my $path_info_html = ascii_html($path_info);
315         my $h = [qw(Content-Type text/html Content-Length), undef];
316         my $gzf = gzf_maybe($h, $env) || PublicInbox::NoopFilter::new();
317         $gzf->zmore("<html><head><title>Index of $path_info_html</title>" .
318                 ${$self->{style}} .
319                 "</head><body><pre>Index of $path_info_html</pre><hr><pre>\n");
320         $gzf->zmore(join("\n", @entries));
321         my $out = $gzf->zflush("</pre><hr></body></html>\n");
322         $h->[3] = bytes::length($out);
323         [ 200, $h, [ $out ] ]
324 }
325
326 sub call { # PSGI app endpoint
327         my ($self, $env) = @_;
328         return r(405) if $env->{REQUEST_METHOD} !~ /\A(?:GET|HEAD)\z/;
329         my $path_info = $env->{PATH_INFO};
330         return r(403) if index($path_info, "\0") >= 0;
331         my (@parts) = split(m!/+!, $path_info, -1);
332         return r(403) if grep(/\A(?:\.\.)\z/, @parts) || $parts[0] ne '';
333
334         my $fs_path = join('/', $self->{docroot}, @parts);
335         return dir_response($self, $env, $fs_path) if $parts[-1] eq '';
336
337         my $res = response($env, [], $fs_path);
338         $res->[0] == 404 && -d $fs_path ? redirect_slash($env) : $res;
339 }
340
341 1;