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