perl-static-file-server.pl 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. #!/usr/bin/env starman
  2. use strict;
  3. use warnings;
  4. use POSIX();
  5. use Mojo::File;
  6. use Plack::MIME;
  7. use IO::Compress::Gzip;
  8. use Time::HiRes qw{tv_interval};
  9. # For CGI features
  10. use HTTP::Parser::XS qw{HEADERS_AS_HASHREF};
  11. use CGI::Emulate::PSGI;
  12. use Date::Format qw{strftime};
  13. use List::Util();
  14. use File::Find;
  15. use Sys::Hostname();
  16. use Plack::MIME ();
  17. use DateTime::Format::HTTP();
  18. use Time::HiRes qw{gettimeofday tv_interval};
  19. use Log::Dispatch;
  20. use Log::Dispatch::Screen;
  21. use Log::Dispatch::FileRotate;
  22. #1MB chunks
  23. our $CHUNK_SEP = 'perlfsSep666YOLO42069';
  24. our $CHUNK_SIZE = 1024000;
  25. our %content_types = (
  26. text => "text/plain",
  27. html => "text/html",
  28. json => "application/json",
  29. blob => "application/octet-stream",
  30. xml => "text/xml",
  31. xsl => "text/xsl",
  32. css => "text/css",
  33. rss => "application/rss+xml",
  34. email => "multipart/related",
  35. );
  36. our %byct = reverse %content_types;
  37. our %cache_control = (
  38. revalidate => "no-cache, max-age=0",
  39. nocache => "no-store",
  40. static => "public, max-age=604800, immutable",
  41. );
  42. #TODO consider integrating libfile
  43. #Stuff that isn't in upstream finders
  44. my %extra_types = (
  45. '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  46. );
  47. my $ct = 'Content-type';
  48. my $LOGNAME = -d '/var/log' ? '/var/log/www/perlfs.log' : '~/.perlfs/perlfs.log';
  49. $LOGNAME = $ENV{CUSTOM_LOG} if $ENV{CUSTOM_LOG};
  50. my $LEVEL = $ENV{WWW_VERBOSE} ? 'debug' : 'info';
  51. # By default only log requests & warnings.
  52. # Otherwise emit debug messages.
  53. my $rotate = Log::Dispatch::FileRotate->new(
  54. name => 'tcms',
  55. filename => $LOGNAME,
  56. min_level => $LEVEL,
  57. 'mode' => 'append',
  58. size => 10 * 1024 * 1024,
  59. max => 6,
  60. );
  61. # Only send fatal events/errors to stdout
  62. my $screen = Log::Dispatch::Screen->new(
  63. name => 'screen',
  64. min_level => $ENV{DEBUG} ? 'debug' : 'error',
  65. );
  66. our $log = Log::Dispatch->new();
  67. $log->add($rotate);
  68. $log->add($screen);
  69. #memoize
  70. my $rq;
  71. our $ip;
  72. my $cur_query = {};
  73. #XXX make perl -c quit whining
  74. BEGIN {
  75. $ip = '0.0.0.0';
  76. }
  77. sub _log {
  78. my ( $msg ) = @_;
  79. $msg //= "No message passed. This is almost certainly a bug. ";
  80. #XXX Log lines must start as an ISO8601 date, anything else breaks fail2ban's beautiful mind
  81. my $tstamp = POSIX::strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
  82. return "$tstamp : $ip $msg\n";
  83. }
  84. sub INFO {
  85. $log->info( _log( shift ) );
  86. }
  87. # File serving
  88. # Let's walk the directory and build our routes ahead of time.
  89. my %routes;
  90. File::Find::find({ wanted => sub {
  91. $routes{$_} = \&serve;
  92. }, follow => 1, no_chdir => 1},
  93. 'www');
  94. =head2 serve
  95. Serve a file, with options to stream and cache the output.
  96. =cut
  97. sub serve {
  98. my ( $fullpath, $path, $start, $streaming, $ranges, $last_fetch, $deflate ) = @_;
  99. $last_fetch ||= 0;
  100. $deflate ||= 0;
  101. my $mf = Mojo::File->new($path);
  102. my $ext = '.' . $mf->extname();
  103. my $ft;
  104. if ($ext) {
  105. $ft = Plack::MIME->mime_type($ext) if $ext;
  106. $ft ||= $extra_types{$ext} if exists $extra_types{$ext};
  107. }
  108. $ft ||= $content_types{text};
  109. my @headers = ( $ct => $ft );
  110. #TODO use static Cache-Control for everything but JS/CSS?
  111. push( @headers, 'Cache-control' => $cache_control{revalidate} );
  112. push( @headers, 'Accept-Ranges' => 'bytes' );
  113. my $mt = ( stat($path) )[9];
  114. my $sz = ( stat(_) )[7];
  115. my @gm = gmtime($mt);
  116. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  117. my $code = $mt > $last_fetch ? 200 : 304;
  118. push( @headers, "Last-Modified" => $now_string );
  119. push( @headers, 'Vary' => 'Accept-Encoding' );
  120. if ( open( my $fh, '<', $path ) ) {
  121. return _range( $fullpath, $fh, $ranges, $sz, @headers ) if @$ranges && $streaming;
  122. # Transfer-encoding: chunked
  123. return sub {
  124. my $responder = shift;
  125. push( @headers, 'Content-Length' => $sz );
  126. my $writer = $responder->( [ $code, \@headers ] );
  127. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  128. $writer->write($buf);
  129. }
  130. close $fh;
  131. $writer->close;
  132. }
  133. if $streaming && $sz > $CHUNK_SIZE;
  134. #Return data in the event the caller does not support deflate
  135. if ( !$deflate ) {
  136. push( @headers, "Content-Length" => $sz );
  137. # Append server-timing headers
  138. my $tot = tv_interval($start) * 1000;
  139. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  140. return [ $code, \@headers, $fh ];
  141. }
  142. #Compress everything less than 1MB
  143. push( @headers, "Content-Encoding" => "gzip" );
  144. my $dfh;
  145. IO::Compress::Gzip::gzip( $fh => \$dfh );
  146. print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
  147. push( @headers, "Content-Length" => length($dfh) );
  148. INFO("GET 200 $fullpath");
  149. # Append server-timing headers
  150. my $tot = tv_interval($start) * 1000;
  151. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  152. return [ $code, \@headers, [$dfh] ];
  153. }
  154. INFO("GET 403 $fullpath");
  155. return [ 403, [ $ct => $content_types{text} ], ["Forbidden"] ];
  156. }
  157. sub _range {
  158. my ( $fullpath, $fh, $ranges, $sz, %headers ) = @_;
  159. # Set mode
  160. my $primary_ct = "Content-Type: $headers{'Content-type'}";
  161. my $is_multipart = scalar(@$ranges) > 1;
  162. if ($is_multipart) {
  163. $headers{'Content-type'} = "multipart/byteranges; boundary=$CHUNK_SEP";
  164. }
  165. my $code = 206;
  166. my $fc = '';
  167. # Calculate the content-length up-front. We have to fix unspecified lengths first, and reject bad requests.
  168. foreach my $range (@$ranges) {
  169. $range->[1] //= $sz - 1;
  170. INFO("GET 416 $fullpath");
  171. return [ 416, [%headers], ["Requested range not satisfiable"] ] if $range->[0] > $sz || $range->[0] < 0 || $range->[1] < 0 || $range->[0] > $range->[1];
  172. }
  173. $headers{'Content-Length'} = List::Util::sum( map { my $arr = $_; $arr->[1] + 1, -$arr->[0] } @$ranges );
  174. #XXX Add the entity header lengths to the value - should hash-ify this to DRY
  175. if ($is_multipart) {
  176. foreach my $range (@$ranges) {
  177. $headers{'Content-Length'} += length("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n");
  178. $fc = "\n";
  179. }
  180. $headers{'Content-Length'} += length("\n--$CHUNK_SEP\--\n");
  181. $fc = '';
  182. }
  183. return sub {
  184. my $responder = shift;
  185. my $writer;
  186. foreach my $range (@$ranges) {
  187. $headers{'Content-Range'} = "bytes $range->[0]-$range->[1]/$sz" unless $is_multipart;
  188. $writer //= $responder->( [ $code, [%headers] ] );
  189. $writer->write("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n") if $is_multipart;
  190. $fc = "\n";
  191. my $len = List::Util::min( $sz, $range->[1] + 1 ) - $range->[0];
  192. $fh->seek( $range->[0], 0 );
  193. while ($len) {
  194. $fh->read( my $buf, List::Util::min( $len, $CHUNK_SIZE ) );
  195. $writer->write($buf);
  196. # Adjust for amount written
  197. $len = List::Util::max( $len - $CHUNK_SIZE, 0 );
  198. }
  199. }
  200. $fh->close();
  201. $writer->write("\n--$CHUNK_SEP\--\n") if $is_multipart;
  202. $writer->close;
  203. };
  204. }
  205. sub _generic {
  206. my ( $type, $code ) = @_;
  207. return [$code, [$ct => $content_types{text}], ["$type"]];
  208. }
  209. sub _notfound {
  210. my $query = shift;
  211. INFO("$query->{method} 404 $query->{fullpath}");
  212. return _generic( 'not found', 404 );
  213. }
  214. sub _forbidden {
  215. my $query = shift;
  216. INFO("$query->{method} 403 $query->{fullpath}");
  217. return _generic( 'forbidden', 403 );
  218. }
  219. sub _badrequest {
  220. my $query = shift;
  221. INFO("$query->{method} 400 $query->{fullpath}");
  222. return _generic( 'bad request', 400 );
  223. }
  224. sub _toolong {
  225. my $query = shift;
  226. INFO("$query->{method} 419 $query->{fullpath}");
  227. return _generic( 'URI too long', 419 );
  228. }
  229. sub _error {
  230. my $query = shift;
  231. INFO("$query->{method} 500 $query->{fullpath}");
  232. return _generic( 'Internal Server Error', 500 );
  233. }
  234. my %etags;
  235. my $domain;
  236. sub _app {
  237. # Start the server timing clock
  238. my $start = [gettimeofday];
  239. my $env = shift;
  240. # Discard the path used in the log, it's too long and enough 4xx error code = ban
  241. return _toolong({ method => $env->{REQUEST_METHOD}, fullpath => '...' }) if length( $env->{REQUEST_URI} ) > 2048;
  242. # Various stuff important for logging requests
  243. $domain //= eval { Sys::Hostname::hostname() } // $env->{HTTP_X_FORWARDED_HOST} || $env->{HTTP_HOST};
  244. my $path = $env->{PATH_INFO};
  245. my $port = $env->{HTTP_X_FORWARDED_PORT} // $env->{HTTP_PORT};
  246. my $pport = defined $port ? ":$port" : "";
  247. my $scheme = $env->{'psgi.url_scheme'} // 'http';
  248. my $method = $env->{REQUEST_METHOD};
  249. # It's important that we log what the user ACTUALLY requested rather than the rewritten path later on.
  250. my $fullpath = "$scheme://$domain$pport$path";
  251. # sigdie can now "do the right thing"
  252. $cur_query = { route => $path, fullpath => $path, method => $method };
  253. # Check eTags. If we don't know about it, just assume it's good and lazily fill the cache
  254. # XXX yes, this allows cache poisoning...but only for logged in users!
  255. if ( $env->{HTTP_IF_NONE_MATCH} ) {
  256. INFO("$env->{REQUEST_METHOD} 304 $fullpath");
  257. return [ 304, [], [''] ] if $env->{HTTP_IF_NONE_MATCH} eq ( $etags{ $env->{REQUEST_URI} } || '' );
  258. $etags{ $env->{REQUEST_URI} } = $env->{HTTP_IF_NONE_MATCH} unless exists $etags{ $env->{REQUEST_URI} };
  259. }
  260. # TODO: Actually do something with the language passed to the renderer
  261. my $lang = $env->{HTTP_ACCEPT_LANGUAGE};
  262. #TODO: Actually do something with the acceptable output formats in the renderer
  263. my $accept = $env->{HTTP_ACCEPT};
  264. my $last_fetch = 0;
  265. if ( $env->{HTTP_IF_MODIFIED_SINCE} ) {
  266. $last_fetch = DateTime::Format::HTTP->parse_datetime( $env->{HTTP_IF_MODIFIED_SINCE} )->epoch();
  267. }
  268. # Figure out if we want compression or not
  269. my $alist = $env->{HTTP_ACCEPT_ENCODING} || '';
  270. $alist =~ s/\s//g;
  271. my @accept_encodings;
  272. @accept_encodings = split( /,/, $alist );
  273. my $deflate = grep { 'gzip' eq $_ } @accept_encodings;
  274. # Set the IP of the request so we can fail2ban
  275. $ip = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR};
  276. my $streaming = $env->{'psgi.streaming'};
  277. # Handle HTTP range/streaming requests
  278. my $range = $env->{HTTP_RANGE} || "bytes=0-" if $env->{HTTP_RANGE} || $env->{HTTP_IF_RANGE};
  279. my @ranges;
  280. if ($range) {
  281. $range =~ s/bytes=//g;
  282. push(
  283. @ranges,
  284. map {
  285. [ split( /-/, $_ ) ];
  286. } split( /,/, $range )
  287. );
  288. }
  289. # Do a case insensitive match, because osx and windows
  290. my $file_actual = List::Util::first { lc($_) eq lc("www$path") } keys(%routes);
  291. # Dirindices
  292. if (!$file_actual) {
  293. my $dirindex = lc("www$path/index.htm");
  294. $dirindex =~ s|//|/|g;
  295. $file_actual = List::Util::first { index(lc($_), $dirindex) == 0 } keys(%routes);
  296. }
  297. my $file_is_cgi = $file_actual ? -x $file_actual : 0;
  298. return cgi( $env, $fullpath, $file_actual, $last_fetch, $deflate) if $file_is_cgi;
  299. return serve( $fullpath, $file_actual, $start, $streaming, \@ranges, $last_fetch, $deflate ) if $file_actual;
  300. return _notfound($cur_query);
  301. }
  302. # Read until we are done with headers, then pass off the filehandle to PSGI for streaming.
  303. # TODO support compression
  304. sub cgi {
  305. my ( $env, $fullpath, $file_actual, $last_fetch, $deflate) = @_;
  306. my $interpreter = $^X;
  307. # Setup the CGI vars they expect
  308. local %ENV = (%ENV, CGI::Emulate::PSGI->emulate_environment($env));
  309. my $pid = open(my $out, '-|', "$file_actual");
  310. my ($code, %headers) = _extract_headers($out, $last_fetch);
  311. return sub {
  312. my $responder = shift;
  313. my $writer = $responder->( [ $code, [%headers] ] );
  314. while ( $out->read( my $buf, $CHUNK_SIZE ) ) {
  315. $writer->write($buf);
  316. }
  317. $writer->close;
  318. # Wait on the CGI script to do whatever it's doing after closing stdout
  319. close $out;
  320. waitpid($pid, 0);
  321. };
  322. }
  323. sub _extract_headers {
  324. my ($fh, $last_fetch) = @_;
  325. my $headers = '';
  326. # NOTE: this is relying on while advancing the file pointer
  327. while (<$fh>) {
  328. last if $_ eq "\n";
  329. $headers .= $_;
  330. }
  331. my ( undef, undef, $status, undef, $headers_parsed ) = HTTP::Parser::XS::parse_http_response( "$headers\n", HEADERS_AS_HASHREF );
  332. my $mt = ( stat($fh) )[9];
  333. my @gm = gmtime($mt);
  334. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  335. my $code = $mt > $last_fetch ? $status : 304;
  336. $headers_parsed->{"Last-Modified"} = $now_string;
  337. return ($code, %$headers_parsed);
  338. }
  339. our $app = sub {
  340. return eval { _app(@_) } || do {
  341. my $env = shift;
  342. $env->{'psgi.errors'}->print($@);
  343. # Redact the stack trace past line 1, it usually has things which should not be shown
  344. $cur_query->{message} = $@;
  345. $cur_query->{message} =~ s/\n.*//g if $cur_query->{message};
  346. return _error($cur_query);
  347. };
  348. };