TPSGI.pm 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. package TPSGI;
  2. # Abstract: Abstraction layer around starman to become a general purpose HTTP server
  3. use strict;
  4. use warnings;
  5. use feature qw{state};
  6. use Carp::Always;
  7. use POSIX();
  8. use Mojo::File;
  9. use Plack::MIME;
  10. use IO::Compress::Gzip;
  11. use Time::HiRes qw{tv_interval};
  12. # For CGI features
  13. use HTTP::Parser::XS qw{HEADERS_AS_HASHREF};
  14. use CGI::Emulate::PSGI;
  15. use Date::Format qw{strftime};
  16. use List::Util();
  17. use File::Find;
  18. use Sys::Hostname();
  19. use Plack::MIME ();
  20. use DateTime::Format::HTTP();
  21. use Time::HiRes qw{gettimeofday tv_interval};
  22. use File::Touch;
  23. use File::Path;
  24. use Cwd qw{abs_path};
  25. use File::Basename qw{dirname basename};
  26. use Log::Dispatch;
  27. use Log::Dispatch::Screen;
  28. use Log::Dispatch::FileRotate;
  29. #1MB chunks
  30. our $CHUNK_SEP = 'perlfsSep666YOLO42069';
  31. our $CHUNK_SIZE = 1024000;
  32. our %content_types = (
  33. text => "text/plain",
  34. html => "text/html",
  35. json => "application/json",
  36. blob => "application/octet-stream",
  37. xml => "text/xml",
  38. xsl => "text/xsl",
  39. css => "text/css",
  40. rss => "application/rss+xml",
  41. email => "multipart/related",
  42. );
  43. our %byct = reverse %content_types;
  44. our %cache_control = (
  45. revalidate => "no-cache, max-age=0",
  46. nocache => "no-store",
  47. static => "public, max-age=604800, immutable",
  48. );
  49. #TODO consider integrating libfile
  50. #Stuff that isn't in upstream finders
  51. my %extra_types = (
  52. '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  53. );
  54. my $ct = 'Content-type';
  55. #memoize
  56. my $rq;
  57. my $cur_query = {};
  58. sub log {
  59. my $self = shift;
  60. state $log;
  61. return $log if $log;
  62. my $LOGNAME = abs_path('.').'/log/tpsgi.log';
  63. $LOGNAME = $self->{custom_log} if $self->{custom_log};
  64. my $LOGDIR = dirname($LOGNAME);
  65. File::Path::make_path($LOGDIR) unless -d $LOGDIR;
  66. File::Touch::touch($LOGNAME) unless -f $LOGNAME;
  67. my $LEVEL = $self->{verbose} ? 'debug' : 'info';
  68. # By default only log requests & warnings.
  69. # Otherwise emit debug messages.
  70. my $rotate = Log::Dispatch::FileRotate->new(
  71. name => 'tcms',
  72. filename => $LOGNAME,
  73. min_level => $LEVEL,
  74. 'mode' => 'append',
  75. size => 10 * 1024 * 1024,
  76. max => 6,
  77. );
  78. # Only send fatal events/errors to stdout
  79. my $screen = Log::Dispatch::Screen->new(
  80. name => 'screen',
  81. min_level => $LEVEL,
  82. );
  83. $log = Log::Dispatch->new();
  84. $log->add($rotate);
  85. $log->add($screen);
  86. foreach my $logger (@{$self->{loggers}}) {
  87. $log->add($logger->new( min_level => $LEVEL, log_dir => $LOGDIR ));
  88. }
  89. $log->info( $self->_log("Opening Log $LOGNAME at $LEVEL level") );
  90. return $log;
  91. }
  92. sub _log {
  93. my ( $self, $msg ) = @_;
  94. $msg //= "No message passed. This is almost certainly a bug. ";
  95. #XXX Log lines must start as an ISO8601 date, anything else breaks fail2ban's beautiful mind
  96. my $tstamp = POSIX::strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
  97. return "[Worker $$] $tstamp : $self->{ip} $msg\n";
  98. }
  99. # Logger short cuts
  100. sub DEBUG {
  101. my $self = shift;
  102. $self->log->debug( $self->_log( shift ) );
  103. }
  104. sub INFO {
  105. my $self = shift;
  106. $self->log->info( $self->_log( shift ) );
  107. }
  108. sub NOTE {
  109. my $self = shift;
  110. $self->log->notice( $self->_log( shift ) );
  111. }
  112. sub WARN {
  113. my $self = shift;
  114. $self->log->warning( $self->_log( shift ) );
  115. }
  116. sub ERROR {
  117. my $self = shift;
  118. $self->log->error( $self->_log( shift ) );
  119. }
  120. sub CRIT {
  121. my $self = shift;
  122. $self->log->critical( $self->_log( shift ) );
  123. }
  124. sub ALERT {
  125. my $self = shift;
  126. $self->log->alert( $self->_log( shift ) );
  127. }
  128. sub EMERG {
  129. my $self = shift;
  130. $self->log->emergency( $self->_log( shift ) );
  131. }
  132. sub FATAL {
  133. my $self = shift;
  134. $self->log->log_and_die( level => 'emergency', message => $self->_log( shift ) );
  135. }
  136. =head1 new(%options)
  137. Options are:
  138. custom_log
  139. verbose
  140. routers
  141. loggers
  142. authn
  143. =cut
  144. sub new {
  145. my ($class, %options) = @_;
  146. my %routes;
  147. no strict 'refs';
  148. foreach my $route (@{$options{routers}}) {
  149. # The router needs to exist and have nonzero numbers of routes.
  150. my ($package) = basename($route) =~ m/(\S+)\.pm$/;
  151. my $r = "$package\:\:routes";
  152. my $success = eval { require $route; 1; };
  153. if ($success) {
  154. my $pkg_routes = *$r{HASH};
  155. @routes{keys(%$pkg_routes)} = values(%$pkg_routes);
  156. } else {
  157. die "Could not load $route!";
  158. }
  159. }
  160. use strict 'refs';
  161. # Set the pattern used to discover the route if needed later.
  162. foreach my $route (keys(%routes)) {
  163. $routes{$route}{pattern} = $route;
  164. }
  165. $options{indices} //=[];
  166. $options{indices} = [@{$options{indices}},qw{index.html index.htm index.cgi}];
  167. $options{routes} = \%routes;
  168. $options{ip} = '0.0.0.0';
  169. return bless(\%options, $class);
  170. }
  171. sub indices {
  172. my $self = shift;
  173. return $self->{indices};
  174. }
  175. sub routes {
  176. my $self = shift;
  177. return $self->{routes};
  178. }
  179. =head2 serve
  180. Serve a file, with options to stream and cache the output.
  181. =cut
  182. sub serve {
  183. my ( $self, $fullpath, $path, $start, $streaming, $ranges, $last_fetch, $deflate ) = @_;
  184. $last_fetch ||= 0;
  185. $deflate ||= 0;
  186. my $mf = Mojo::File->new($path);
  187. my $ext = '.' . $mf->extname();
  188. my $ft;
  189. if ($ext) {
  190. $ft = Plack::MIME->mime_type($ext) if $ext;
  191. $ft ||= $extra_types{$ext} if exists $extra_types{$ext};
  192. }
  193. $ft ||= $content_types{text};
  194. my @headers = ( $ct => $ft );
  195. #TODO use static Cache-Control for everything but JS/CSS?
  196. push( @headers, 'Cache-control' => $cache_control{revalidate} );
  197. push( @headers, 'Accept-Ranges' => 'bytes' );
  198. my $mt = ( stat($path) )[9];
  199. my $sz = ( stat(_) )[7];
  200. my @gm = gmtime($mt);
  201. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  202. my $code = $mt > $last_fetch ? 200 : 304;
  203. push( @headers, "Last-Modified" => $now_string );
  204. push( @headers, 'Vary' => 'Accept-Encoding' );
  205. if ( open( my $fh, '<', $path ) ) {
  206. return $self->_range( $fullpath, $fh, $ranges, $sz, @headers ) if @$ranges && $streaming;
  207. # Transfer-encoding: chunked
  208. return sub {
  209. my $responder = shift;
  210. push( @headers, 'Content-Length' => $sz );
  211. my $writer = $responder->( [ $code, \@headers ] );
  212. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  213. $writer->write($buf);
  214. }
  215. close $fh;
  216. $writer->close;
  217. }
  218. if $streaming && $sz > $CHUNK_SIZE;
  219. #Return data in the event the caller does not support deflate
  220. if ( !$deflate ) {
  221. push( @headers, "Content-Length" => $sz );
  222. # Append server-timing headers
  223. my $tot = tv_interval($start) * 1000;
  224. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  225. return [ $code, \@headers, $fh ];
  226. }
  227. #Compress everything less than 1MB
  228. push( @headers, "Content-Encoding" => "gzip" );
  229. my $dfh;
  230. IO::Compress::Gzip::gzip( $fh => \$dfh );
  231. print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
  232. push( @headers, "Content-Length" => length($dfh) );
  233. $self->INFO("GET 200 $fullpath");
  234. # Append server-timing headers
  235. my $tot = tv_interval($start) * 1000;
  236. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  237. return [ $code, \@headers, [$dfh] ];
  238. }
  239. $self->INFO("GET 403 $fullpath");
  240. return [ 403, [ $ct => $content_types{text} ], ["Forbidden"] ];
  241. }
  242. sub _range {
  243. my ( $self, $fullpath, $fh, $ranges, $sz, %headers ) = @_;
  244. # Set mode
  245. my $primary_ct = "Content-Type: $headers{'Content-type'}";
  246. my $is_multipart = scalar(@$ranges) > 1;
  247. if ($is_multipart) {
  248. $headers{'Content-type'} = "multipart/byteranges; boundary=$CHUNK_SEP";
  249. }
  250. my $code = 206;
  251. my $fc = '';
  252. # Calculate the content-length up-front. We have to fix unspecified lengths first, and reject bad requests.
  253. foreach my $range (@$ranges) {
  254. $range->[1] //= $sz - 1;
  255. $self->INFO("GET 416 $fullpath");
  256. return [ 416, [%headers], ["Requested range not satisfiable"] ] if $range->[0] > $sz || $range->[0] < 0 || $range->[1] < 0 || $range->[0] > $range->[1];
  257. }
  258. $headers{'Content-Length'} = List::Util::sum( map { my $arr = $_; $arr->[1] + 1, -$arr->[0] } @$ranges );
  259. #XXX Add the entity header lengths to the value - should hash-ify this to DRY
  260. if ($is_multipart) {
  261. foreach my $range (@$ranges) {
  262. $headers{'Content-Length'} += length("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n");
  263. $fc = "\n";
  264. }
  265. $headers{'Content-Length'} += length("\n--$CHUNK_SEP\--\n");
  266. $fc = '';
  267. }
  268. return sub {
  269. my $responder = shift;
  270. my $writer;
  271. foreach my $range (@$ranges) {
  272. $headers{'Content-Range'} = "bytes $range->[0]-$range->[1]/$sz" unless $is_multipart;
  273. $writer //= $responder->( [ $code, [%headers] ] );
  274. $writer->write("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n") if $is_multipart;
  275. $fc = "\n";
  276. my $len = List::Util::min( $sz, $range->[1] + 1 ) - $range->[0];
  277. $fh->seek( $range->[0], 0 );
  278. while ($len) {
  279. $fh->read( my $buf, List::Util::min( $len, $CHUNK_SIZE ) );
  280. $writer->write($buf);
  281. # Adjust for amount written
  282. $len = List::Util::max( $len - $CHUNK_SIZE, 0 );
  283. }
  284. }
  285. $fh->close();
  286. $writer->write("\n--$CHUNK_SEP\--\n") if $is_multipart;
  287. $writer->close;
  288. };
  289. }
  290. sub _generic {
  291. my ( $type, $code ) = @_;
  292. return [$code, [$ct => $content_types{html}], ["$type"]];
  293. }
  294. =head2 notfound, forbidden, badrequest, toolong, error
  295. If you need to return these HTTP errors, return these within a route:
  296. sub route {
  297. my ($self, $query) = @_;
  298. ...
  299. return $self->notfound($query);
  300. }
  301. =cut
  302. sub notfound {
  303. my ($self, $query, $body) = @_;
  304. $self->INFO("$query->{method} 404 $query->{fullpath}");
  305. $body //= 'Not Found';
  306. return _generic( $body, 404 );
  307. }
  308. sub forbidden {
  309. my ($self, $query, $body) = @_;
  310. $self->INFO("$query->{method} 403 $query->{fullpath}");
  311. $body //= 'Forbidden';
  312. return _generic( 'Forbidden', 403 );
  313. }
  314. sub badrequest {
  315. my ($self, $query, $body) = @_;
  316. $self->INFO("$query->{method} 400 $query->{fullpath}");
  317. $body //= 'Bad Request';
  318. return _generic( $body, 400 );
  319. }
  320. sub toolong {
  321. my ($self, $query, $body) = @_;
  322. $self->INFO("$query->{method} 419 $query->{fullpath}");
  323. $body //= 'URI too long';
  324. return _generic( $body, 419 );
  325. }
  326. sub error {
  327. my ($self, $query, $body) = @_;
  328. my $method = $query->{method} // "?";
  329. my $fp = $query->{fullpath} // "?";
  330. $body //= 'Internal Server Error';
  331. $self->INFO("$method 500 $fp");
  332. return _generic( $body, 500 );
  333. }
  334. sub unavailable {
  335. my ($self, $query, $body) = @_;
  336. $self->INFO("$query->{method} 503 $query->{fullpath}");
  337. $body //= 'Service Unavailable';
  338. return _generic( $body, 503 );
  339. }
  340. my %etags;
  341. my $domain;
  342. sub app {
  343. my $self = shift;
  344. # Start the server timing clock
  345. my $start = [gettimeofday];
  346. my $env = shift;
  347. # Discard the path used in the log, it's too long and enough 4xx error code = ban
  348. return $self->toolong({ method => $env->{REQUEST_METHOD}, fullpath => '...' }) if length( $env->{REQUEST_URI} ) > 2048;
  349. # Various stuff important for logging requests
  350. $domain //= eval { Sys::Hostname::hostname() } // $env->{HTTP_X_FORWARDED_HOST} || $env->{HTTP_HOST};
  351. my $path = $env->{PATH_INFO};
  352. my $port = $env->{HTTP_X_FORWARDED_PORT} // $env->{HTTP_PORT};
  353. my $pport = defined $port ? ":$port" : "";
  354. my $scheme = $env->{'psgi.url_scheme'} // 'http';
  355. my $method = $env->{REQUEST_METHOD};
  356. # It's important that we log what the user ACTUALLY requested rather than the rewritten path later on.
  357. my $fullpath = "$scheme://$domain$pport$path";
  358. # sigdie can now "do the right thing"
  359. $self->{cur_query} = { route => $path, fullpath => $path, method => $method };
  360. # Check eTags. If we don't know about it, just assume it's good and lazily fill the cache
  361. # XXX yes, this allows cache poisoning...but only for logged in users!
  362. if ( $env->{HTTP_IF_NONE_MATCH} ) {
  363. $self->INFO("$env->{REQUEST_METHOD} 304 $fullpath");
  364. return [ 304, [], [''] ] if $env->{HTTP_IF_NONE_MATCH} eq ( $etags{ $env->{REQUEST_URI} } || '' );
  365. $etags{ $env->{REQUEST_URI} } = $env->{HTTP_IF_NONE_MATCH} unless exists $etags{ $env->{REQUEST_URI} };
  366. }
  367. # TODO: Actually do something with the language passed to the renderer
  368. my $lang = $env->{HTTP_ACCEPT_LANGUAGE};
  369. #TODO: Actually do something with the acceptable output formats in the renderer
  370. my $accept = $env->{HTTP_ACCEPT};
  371. my $last_fetch = 0;
  372. if ( $env->{HTTP_IF_MODIFIED_SINCE} ) {
  373. $last_fetch = DateTime::Format::HTTP->parse_datetime( $env->{HTTP_IF_MODIFIED_SINCE} )->epoch();
  374. }
  375. # Figure out if we want compression or not
  376. my $alist = $env->{HTTP_ACCEPT_ENCODING} || '';
  377. $alist =~ s/\s//g;
  378. my @accept_encodings;
  379. @accept_encodings = split( /,/, $alist );
  380. my $deflate = grep { 'gzip' eq $_ } @accept_encodings;
  381. # Set the IP of the request so we can fail2ban
  382. $self->{ip} = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR};
  383. my $streaming = $env->{'psgi.streaming'};
  384. # If we have an actual route, just use it.
  385. my $r = $self->routes;
  386. my $route_actual = $r->{$path};
  387. # Might be a regexed route.
  388. if (!$route_actual) {
  389. my $matched = List::Util::first { exists $r->{$_}{pattern} && $path =~ m/^$r->{$_}{pattern}$/ } keys(%$r);
  390. $route_actual = $r->{$matched} if $matched;
  391. }
  392. return $self->route( $route_actual, $env, $fullpath, $path, $start, $last_fetch, $deflate ) if $route_actual;
  393. # Do a case insensitive match, because osx and windows
  394. my $file_possible = lc("www$path");
  395. my $file_actual = $file_possible if -f $file_possible;
  396. # Dirindices
  397. if (-d $file_possible) {
  398. foreach my $index ($self->indices) {
  399. next if $file_actual;
  400. my $dirindex = lc("www$path/$index");
  401. $dirindex =~ s|//|/|g;
  402. $file_actual = $dirindex if -f $dirindex;
  403. }
  404. }
  405. my $file_is_cgi = appears_executable($file_actual);
  406. return $self->cgi( $env, $fullpath, $file_actual, $last_fetch, $deflate) if $file_is_cgi;
  407. $self->INFO("Attempting to serve $fullpath [".($file_possible // "")."]");
  408. my @ranges = parse_ranges($env);
  409. return $self->serve( $fullpath, $file_actual, $start, $streaming, \@ranges, $last_fetch, $deflate ) if $file_actual;
  410. return $self->notfound($self->{cur_query});
  411. }
  412. sub parse_ranges {
  413. my $env = shift;
  414. # Handle HTTP range/streaming requests
  415. my $range = $env->{HTTP_RANGE} || "bytes=0-" if $env->{HTTP_RANGE} || $env->{HTTP_IF_RANGE};
  416. my @ranges;
  417. if ($range) {
  418. $range =~ s/bytes=//g;
  419. push(
  420. @ranges,
  421. map {
  422. [ split( /-/, $_ ) ];
  423. } split( /,/, $range )
  424. );
  425. }
  426. return @ranges;
  427. }
  428. my @executable_extensions = qw{cgi sh exe pl php py};
  429. sub appears_executable {
  430. my $subj = shift;
  431. # Definitely not executable if it does not exist!
  432. return 0 unless $subj;
  433. return 0 unless -x $subj;
  434. my ($extension) = $subj =~ m/\.(\S+)$/;
  435. return 1 if !$extension;
  436. return 1 if grep { $extension eq $_ } @executable_extensions;
  437. return 0;
  438. }
  439. # Handle actual routes
  440. sub route {
  441. my ($self, $route, $env, $fullpath, $path, $start, $last_fetch, $deflate ) = @_;
  442. my $content_type = $env->{CONTENT_TYPE};
  443. # It is the responsibility of each route to respond to HEAD requests correctly
  444. return $self->badrequest($self->{cur_query}) unless List::Util::any { $_ eq $env->{REQUEST_METHOD} } ('HEAD', $route->{method});
  445. my $callback;
  446. if (exists $route->{callbacks}{'*'}) {
  447. $callback = $route->{callbacks}{'*'};
  448. } else {
  449. return $self->badrequest($self->{cur_query}) unless exists $route->{callbacks}{$content_type};
  450. $callback = $route->{callbacks}{$content_type};
  451. }
  452. # Provide static renders if available.
  453. my $streaming = $env->{'psgi.streaming'};
  454. return $self->static( $fullpath, "$fullpath.z", $start, $streaming ) if -f "statics/$fullpath.z" && $deflate;
  455. return $self->static( $fullpath, $fullpath, $start, $streaming ) if -f "statics/$fullpath";
  456. # Build the query data for passing to a route.
  457. # GET, POST, URI captures, then explicit data overrides.
  458. my $query = $self->extract_query($path, $route, $env);
  459. # allow this stuff to survive down to the end of some routes
  460. $query->{last_fetched} = $last_fetch;
  461. $query->{deflate} = $deflate;
  462. $query->{streaming} = $streaming;
  463. my @ranges = parse_ranges($env);
  464. $query->{ranges} = \@ranges;
  465. $query->{start} = $start;
  466. $query->{fullpath} = $fullpath;
  467. $query->{method} = $route->{method};
  468. # This allows for better error handlers if we die in the route.
  469. $self->{cur_query} = $query;
  470. # Setup the CGI vars they expect IF requested
  471. local %ENV = (%ENV, CGI::Emulate::PSGI->emulate_environment($env)) if $route->{env};
  472. {
  473. my $output = $callback->($self, $query);
  474. # If it's streaming, just hand it off
  475. return $output if ref $output eq 'CODE';
  476. die "$fullpath returned no or malformed data!" unless ref $output eq 'ARRAY' && @$output == 3;
  477. my $pport = defined $query->{port} ? ":$query->{port}" : "";
  478. my %headers = @{$output->[1]};
  479. my $bytes = $headers{'Content-Length'} // '?';
  480. $self->INFO("$env->{REQUEST_METHOD} $output->[0] $bytes $fullpath");
  481. # Append server-timing headers if they aren't present
  482. my $tot = tv_interval($start) * 1000;
  483. push( @{ $output->[1] }, 'Server-Timing' => "app;dur=$tot" ) unless List::Util::any { $_ eq 'Server-Timing' } @{ $output->[1] };
  484. return $output;
  485. }
  486. }
  487. sub extract_query {
  488. my ($self, $path, $route, $env) = @_;
  489. my $query = URL::Encode::url_params_mixed( $env->{QUERY_STRING} ) if $env->{QUERY_STRING};
  490. #Actually parse the POSTDATA and dump it into the QUERY object if this is a POST
  491. if ( $env->{REQUEST_METHOD} eq 'POST' ) {
  492. my $body = HTTP::Body->new( $env->{CONTENT_TYPE}, $env->{CONTENT_LENGTH} );
  493. while ( $env->{'psgi.input'}->read( my $buf, $CHUNK_SIZE ) ) {
  494. $body->add($buf);
  495. }
  496. @$query{ keys( %{ $body->param } ) } = values( %{ $body->param } );
  497. @$query{ keys( %{ $body->upload } ) } = values( %{ $body->upload } );
  498. }
  499. if (ref $route->{captures} eq 'ARRAY') {
  500. my @captures = $path =~ m/^$route->{pattern}$/;
  501. @$query{@{$route->{captures}}} = @captures;
  502. }
  503. if (ref $route->{data} eq 'HASH') {
  504. @$query{keys(%{$route->{data}})} = values(%{$route->{data}});
  505. }
  506. return $query;
  507. }
  508. # Read until we are done with headers, then pass off the filehandle to PSGI for streaming.
  509. # TODO support compression
  510. sub cgi {
  511. my ( $self, $env, $fullpath, $file_actual, $last_fetch, $deflate) = @_;
  512. $self->INFO("Handoff $fullpath to $file_actual for cgi exec");
  513. # Setup the CGI vars they expect
  514. local %ENV = (%ENV, CGI::Emulate::PSGI->emulate_environment($env));
  515. my $pid = open(my $out, '-|', "$file_actual");
  516. my ($code, %headers) = extract_headers($out, $last_fetch);
  517. $code //= 500;
  518. return sub {
  519. my $responder = shift;
  520. my $writer = $responder->( [ $code, [%headers] ] );
  521. while ( $out->read( my $buf, $CHUNK_SIZE ) ) {
  522. $writer->write($buf);
  523. }
  524. $writer->close;
  525. # Wait on the CGI script to do whatever it's doing after closing stdout
  526. close $out;
  527. waitpid($pid, 0);
  528. };
  529. }
  530. sub stream_raw_http {
  531. my ($self, $output, $last_fetch, $callback) = @_;
  532. open(my $out, '<', \$output) or die "I couldn't open a string as a filehandle, which can only happen if you don't compile perl with PerlIO";
  533. my ($code, %headers) = extract_headers($out, $last_fetch, 1);
  534. $code //= 500;
  535. $self->INFO("$self->{cur_query}{method} $code $self->{cur_query}{fullpath}");
  536. return sub {
  537. my $responder = shift;
  538. my $writer = $responder->( [ $code, [%headers] ] );
  539. while ( $out->read( my $buf, $CHUNK_SIZE ) ) {
  540. $writer->write($buf);
  541. }
  542. $writer->close;
  543. close($out);
  544. # Wait on the script to do whatever it's doing after closing stdout
  545. $callback->() if ref $callback eq 'CODE';
  546. };
  547. }
  548. =head2 extract_headers($fh, $last_fetch)
  549. Extract the headers from a filehandle. Set the 'Last Modified' header appropriately based on the output of `stat` on the filehandle.
  550. Returns a code and parsed headers hash. Code will be 304 if the file hasn't changed since $last_fetch, otherwise will be the code in the parsed headers.
  551. =cut
  552. sub extract_headers {
  553. my ($fh, $last_fetch, $is_ref) = @_;
  554. my $headers = '';
  555. # NOTE: this is relying on while advancing the file pointer
  556. while (<$fh>) {
  557. last if $_ eq "\n";
  558. $headers .= $_;
  559. }
  560. my ( undef, undef, $status, undef, $headers_parsed ) = HTTP::Parser::XS::parse_http_response( "$headers\n", HEADERS_AS_HASHREF );
  561. my $code = $status // 200;
  562. $headers_parsed //= {};
  563. if (!$is_ref) {
  564. my $mt = ( stat($fh) )[9];
  565. my @gm = gmtime($mt);
  566. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  567. $code = $mt > $last_fetch ? $status : 304;
  568. $headers_parsed->{"Last-Modified"} = $now_string;
  569. }
  570. return ($code, %$headers_parsed);
  571. }
  572. sub static {
  573. my ( $self, $fullpath, $path, $start, $streaming, $last_fetch ) = @_;
  574. $self->DEBUG("Rendering static for $path");
  575. # XXX because of psgi I can't just vomit the file directly
  576. if ( open( my $fh, '<', "statics/$path" ) ) {
  577. my ($code, $headers_parsed) = (200, {});
  578. ($code, %$headers_parsed) = extract_headers($fh, $last_fetch );
  579. # Append server-timing headers
  580. my $tot = tv_interval($start) * 1000;
  581. $headers_parsed->{'Server-Timing'} = "static;dur=$tot";
  582. #XXX uwsgi just opens the file *again* when we already have a filehandle if it has a path.
  583. # starman by comparison doesn't violate the principle of least astonishment here.
  584. # This is probably a performance optimization, but makes the kind of micromanagement I need to do inconvenient.
  585. # As such, we will just return a stream.
  586. $self->INFO("GET 200 $headers_parsed->{'Content-Length'} $fullpath");
  587. return sub {
  588. my $responder = shift;
  589. #push(@headers, 'Content-Length' => $sz);
  590. my $writer = $responder->( [ $code, [%$headers_parsed] ] );
  591. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  592. $writer->write($buf);
  593. }
  594. close $fh;
  595. $writer->close;
  596. }
  597. if $streaming;
  598. return [ $code, [%$headers_parsed], $fh ];
  599. }
  600. return $self->forbidden($self->{current_query});
  601. }
  602. 1;