TPSGI.pm 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989
  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 FindBin;
  7. use Cwd();
  8. use Carp::Always;
  9. use UUID();
  10. use POSIX();
  11. use Mojo::File;
  12. use Plack::MIME;
  13. use IO::Compress::Gzip;
  14. use Time::HiRes qw{usleep gettimeofday tv_interval};
  15. # For CGI features
  16. use HTTP::Parser::XS qw{HEADERS_AS_HASHREF};
  17. use CGI::Emulate::PSGI;
  18. use Date::Format qw{strftime};
  19. use List::Util();
  20. use File::Find;
  21. use Sys::Hostname();
  22. use Plack::MIME ();
  23. use DateTime::Format::HTTP();
  24. use URL::Encode();
  25. use File::Touch;
  26. use File::Path;
  27. use Cwd qw{abs_path};
  28. use File::Basename qw{dirname basename};
  29. use Log::Dispatch;
  30. use Log::Dispatch::Screen;
  31. use Log::Dispatch::FileRotate;
  32. use Config::Simple;
  33. # Generally we want the users to be able to DB::enable_profile() and DB::disable_profile() as they choose.
  34. BEGIN {
  35. # If we have manually set the NYTPROF var, use it and don't try to control
  36. # When to stop or start it.
  37. my $basedir = Cwd::abs_path("$FindBin::Bin");
  38. $ENV{NYTPROF} ||= "sigexit=1:savesrc=0:start=no:file=$basedir/prof/nytprof.out";
  39. require Devel::NYTProf;
  40. mkdir "$basedir/prof";
  41. }
  42. # We have a DEBUG var which is plus ultra for extra sensitive stuff beyond just passing verbose
  43. my $debug = $ENV{TPSGI_DEBUG};
  44. #1MB chunks
  45. our $CHUNK_SEP = 'perlfsSep666YOLO42069';
  46. our $CHUNK_SIZE = 1024000;
  47. our %content_types = (
  48. text => "text/plain",
  49. html => "text/html",
  50. json => "application/json",
  51. blob => "application/octet-stream",
  52. xml => "text/xml",
  53. xsl => "text/xsl",
  54. css => "text/css",
  55. rss => "application/rss+xml",
  56. email => "multipart/related",
  57. );
  58. our %byct = reverse %content_types;
  59. our %cache_control = (
  60. revalidate => "no-cache, max-age=0",
  61. nocache => "no-store",
  62. static => "public, max-age=604800, immutable",
  63. );
  64. #TODO consider integrating libfile
  65. #Stuff that isn't in upstream finders
  66. my %extra_types = (
  67. '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  68. );
  69. my $ct = 'Content-type';
  70. #memoize
  71. my $rq;
  72. sub request_id {
  73. my ($self, $regenerate) = @_;
  74. return $self->{uuid} if $self->{uuid} && !$regenerate;
  75. $self->{uuid} = UUID::uuid();
  76. return $self->{uuid};
  77. }
  78. sub log {
  79. my $self = shift;
  80. state $log;
  81. return $log if $log;
  82. my $LOGNAME = abs_path('.').'/log/tpsgi.log';
  83. $LOGNAME = $self->{custom_log} if $self->{custom_log};
  84. my $LOGDIR = dirname($LOGNAME);
  85. File::Path::make_path($LOGDIR) unless -d $LOGDIR;
  86. File::Touch::touch($LOGNAME) unless -f $LOGNAME;
  87. my $LEVEL = $self->{verbose} ? 'debug' : 'info';
  88. # By default only log requests & warnings.
  89. # Otherwise emit debug messages.
  90. my $rotate = Log::Dispatch::FileRotate->new(
  91. name => 'tcms',
  92. filename => $LOGNAME,
  93. min_level => $LEVEL,
  94. 'mode' => 'append',
  95. size => 10 * 1024 * 1024,
  96. max => 6,
  97. );
  98. # Only send fatal events/errors to stdout
  99. my $screen = Log::Dispatch::Screen->new(
  100. name => 'screen',
  101. min_level => $LEVEL,
  102. );
  103. $log = Log::Dispatch->new();
  104. $log->add($rotate);
  105. $log->add($screen);
  106. foreach my $logger (@{$self->{loggers}}) {
  107. $log->add($logger->new( min_level => $LEVEL, log_dir => $LOGDIR ));
  108. }
  109. $log->info( $self->_log("Opening Log $LOGNAME at $LEVEL level") );
  110. return $log;
  111. }
  112. sub _log {
  113. my ( $self, $msg ) = @_;
  114. $msg //= "No message passed. This is almost certainly a bug. ";
  115. #XXX Log lines must start as an ISO8601 date, anything else breaks fail2ban's beautiful mind
  116. my $tstamp = POSIX::strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
  117. my $uuid = $self->request_id();
  118. return "[Worker $$] {Request $uuid} $tstamp : $self->{ip} $msg\n";
  119. }
  120. # Logger short cuts
  121. sub DEBUG {
  122. my $self = shift;
  123. $self->log->debug( $self->_log( shift ) );
  124. }
  125. sub INFO {
  126. my $self = shift;
  127. $self->log->info( $self->_log( shift ) );
  128. }
  129. sub NOTE {
  130. my $self = shift;
  131. $self->log->notice( $self->_log( shift ) );
  132. }
  133. sub WARN {
  134. my $self = shift;
  135. $self->log->warning( $self->_log( shift ) );
  136. }
  137. sub ERROR {
  138. my $self = shift;
  139. $self->log->error( $self->_log( shift ) );
  140. }
  141. sub CRIT {
  142. my $self = shift;
  143. $self->log->critical( $self->_log( shift ) );
  144. }
  145. sub ALERT {
  146. my $self = shift;
  147. $self->log->alert( $self->_log( shift ) );
  148. }
  149. sub EMERG {
  150. my $self = shift;
  151. $self->log->emergency( $self->_log( shift ) );
  152. }
  153. sub FATAL {
  154. my $self = shift;
  155. $self->log->log_and_die( level => 'emergency', message => $self->_log( shift ) );
  156. }
  157. =head1 new(%options)
  158. Options are:
  159. custom_log
  160. verbose
  161. routers
  162. loggers
  163. auth
  164. =cut
  165. sub new {
  166. my ($class, %options) = @_;
  167. # Refuse to run as wrong user, all config will be borked otherwise
  168. my $pname = getpwuid($>);
  169. die "Must run as configured user (got: $pname, want: $options{user})!" unless $pname eq ($options{user} // '');
  170. my $self = bless(\%options, $class);
  171. $self->{ip} = '0.0.0.0';
  172. my @routes;
  173. my %aliases;
  174. # XXX TODO make these routes able to be fully qualified namespaces (::)!
  175. no strict 'refs';
  176. foreach my $route (@{$self->{routers}}) {
  177. die "No such routing module $route" unless -f $route;
  178. # The router needs to exist and have nonzero numbers of routes.
  179. my ($package) = basename($route) =~ m/(\S+)\.pm$/;
  180. my $r = "$package\:\:routes";
  181. my $a = "$package\:\:aliases";
  182. my $libdir = dirname($route);
  183. push(@INC, $libdir);
  184. local $@;
  185. $self->DEBUG("require $route");
  186. my $success = eval { require $route; 1; };
  187. if ($success) {
  188. my $pkg_routes = *$r{ARRAY};
  189. for (my $i=0; $i < @$pkg_routes; $i += 2) {
  190. $self->DEBUG("Registered route $pkg_routes->[$i]");
  191. }
  192. push(@routes,@$pkg_routes);
  193. my $pkg_aliases = *$a{HASH};
  194. foreach my $al (keys(%$pkg_aliases)) {
  195. $self->DEBUG("aliased route $al to $pkg_aliases->{$al}");
  196. }
  197. @aliases{keys(%$pkg_aliases)} = values(%$pkg_aliases);
  198. } else {
  199. die "Could not load $route!\n$@\n";
  200. }
  201. }
  202. use strict 'refs';
  203. # Set the pattern used to discover the route if needed later.
  204. for (my $i=0; $i < scalar(@routes); $i += 2 ) {
  205. $routes[$i+1]{pattern} = $routes[$i];
  206. }
  207. $self->{indices} //=[];
  208. $self->{indices} = [@{$self->{indices}},qw{index.html index.htm index.cgi}];
  209. $self->{routes} = \@routes;
  210. $self->{aliases} = \%aliases;
  211. return $self;
  212. }
  213. sub indices {
  214. my $self = shift;
  215. return $self->{indices};
  216. }
  217. sub routes {
  218. my $self = shift;
  219. return $self->{routes};
  220. }
  221. =head2 serve
  222. Serve a file, with options to stream and cache the output.
  223. =cut
  224. sub serve {
  225. my ( $self, $fullpath, $path, $start, $streaming, $ranges, $last_fetch, $deflate ) = @_;
  226. $last_fetch ||= 0;
  227. $deflate ||= 0;
  228. my $mf = Mojo::File->new($path);
  229. my $ext = '.' . $mf->extname();
  230. my $ft;
  231. if ($ext) {
  232. $ft = Plack::MIME->mime_type($ext) if $ext;
  233. $ft ||= $extra_types{$ext} if exists $extra_types{$ext};
  234. }
  235. $ft ||= $content_types{text};
  236. my @headers = ( $ct => $ft );
  237. #TODO use static Cache-Control for everything but JS/CSS?
  238. push( @headers, 'Cache-control' => $cache_control{revalidate} );
  239. push( @headers, 'Accept-Ranges' => 'bytes' );
  240. my $mt = ( stat($path) )[9];
  241. my $sz = ( stat(_) )[7];
  242. my @gm = gmtime($mt);
  243. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  244. my $code = $mt > $last_fetch ? 200 : 304;
  245. push( @headers, "Last-Modified" => $now_string );
  246. push( @headers, 'Vary' => 'Accept-Encoding' );
  247. if ( open( my $fh, '<', $path ) ) {
  248. return $self->_range( $fullpath, $fh, $ranges, $sz, @headers ) if @$ranges && $streaming;
  249. # Transfer-encoding: chunked
  250. return sub {
  251. my $responder = shift;
  252. push( @headers, 'Content-Length' => $sz );
  253. my $writer = $responder->( [ $code, \@headers ] );
  254. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  255. $writer->write($buf);
  256. }
  257. close $fh;
  258. $writer->close;
  259. }
  260. if $streaming && $sz > $CHUNK_SIZE;
  261. #Return data in the event the caller does not support deflate
  262. if ( !$deflate ) {
  263. push( @headers, "Content-Length" => $sz );
  264. # Append server-timing headers
  265. my $tot = tv_interval($start) * 1000;
  266. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  267. return [ $code, \@headers, $fh ];
  268. }
  269. #Compress everything less than 1MB
  270. push( @headers, "Content-Encoding" => "gzip" );
  271. my $dfh;
  272. IO::Compress::Gzip::gzip( $fh => \$dfh );
  273. print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
  274. push( @headers, "Content-Length" => length($dfh) );
  275. $self->INFO("GET 200 $fullpath");
  276. # Append server-timing headers
  277. my $tot = tv_interval($start) * 1000;
  278. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  279. return [ $code, \@headers, [$dfh] ];
  280. }
  281. $self->INFO("GET 403 $fullpath");
  282. return [ 403, [ $ct => $content_types{text} ], ["Forbidden"] ];
  283. }
  284. sub _range {
  285. my ( $self, $fullpath, $fh, $ranges, $sz, %headers ) = @_;
  286. # Set mode
  287. my $primary_ct = "Content-Type: $headers{'Content-type'}";
  288. my $is_multipart = scalar(@$ranges) > 1;
  289. if ($is_multipart) {
  290. $headers{'Content-type'} = "multipart/byteranges; boundary=$CHUNK_SEP";
  291. }
  292. my $code = 206;
  293. my $fc = '';
  294. # Calculate the content-length up-front. We have to fix unspecified lengths first, and reject bad requests.
  295. foreach my $range (@$ranges) {
  296. $range->[1] //= $sz - 1;
  297. $self->INFO("GET 416 $fullpath");
  298. return [ 416, [%headers], ["Requested range not satisfiable"] ] if $range->[0] > $sz || $range->[0] < 0 || $range->[1] < 0 || $range->[0] > $range->[1];
  299. }
  300. $headers{'Content-Length'} = List::Util::sum( map { my $arr = $_; $arr->[1] + 1, -$arr->[0] } @$ranges );
  301. #XXX Add the entity header lengths to the value - should hash-ify this to DRY
  302. if ($is_multipart) {
  303. foreach my $range (@$ranges) {
  304. $headers{'Content-Length'} += length("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n");
  305. $fc = "\n";
  306. }
  307. $headers{'Content-Length'} += length("\n--$CHUNK_SEP\--\n");
  308. $fc = '';
  309. }
  310. return sub {
  311. my $responder = shift;
  312. my $writer;
  313. foreach my $range (@$ranges) {
  314. $headers{'Content-Range'} = "bytes $range->[0]-$range->[1]/$sz" unless $is_multipart;
  315. $writer //= $responder->( [ $code, [%headers] ] );
  316. $writer->write("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n") if $is_multipart;
  317. $fc = "\n";
  318. my $len = List::Util::min( $sz, $range->[1] + 1 ) - $range->[0];
  319. $fh->seek( $range->[0], 0 );
  320. while ($len) {
  321. $fh->read( my $buf, List::Util::min( $len, $CHUNK_SIZE ) );
  322. $writer->write($buf);
  323. # Adjust for amount written
  324. $len = List::Util::max( $len - $CHUNK_SIZE, 0 );
  325. }
  326. }
  327. $fh->close();
  328. $writer->write("\n--$CHUNK_SEP\--\n") if $is_multipart;
  329. $writer->close;
  330. };
  331. }
  332. sub _generic {
  333. my ( $type, $code ) = @_;
  334. $type .= Carp::longmess() if $debug;
  335. return [$code, [$ct => $content_types{html}], ["$type"]];
  336. }
  337. =head2 notfound, forbidden, badrequest, toolong, error
  338. If you need to return these HTTP errors, return these within a route:
  339. sub route {
  340. my ($self, $query) = @_;
  341. ...
  342. return $self->notfound($query);
  343. }
  344. =cut
  345. sub notfound {
  346. my ($self, $query, $body) = @_;
  347. $self->INFO("$query->{method} 404 $query->{fullpath}");
  348. $body //= 'Not Found';
  349. return _generic( $body, 404 );
  350. }
  351. sub forbidden {
  352. my ($self, $query, $body) = @_;
  353. $self->INFO("$query->{method} 403 $query->{fullpath}");
  354. $body //= 'Forbidden';
  355. return _generic( $body, 403 );
  356. }
  357. sub badrequest {
  358. my ($self, $query, $body) = @_;
  359. $self->INFO("$query->{method} 400 $query->{fullpath}");
  360. $body //= 'Bad Request';
  361. return _generic( $body, 400 );
  362. }
  363. sub toolong {
  364. my ($self, $query, $body) = @_;
  365. $self->INFO("$query->{method} 419 $query->{fullpath}");
  366. $body //= 'URI too long';
  367. return _generic( $body, 419 );
  368. }
  369. sub error {
  370. my ($self, $query, $body) = @_;
  371. my $method = $query->{method} // "?";
  372. my $fp = $query->{fullpath} // "?";
  373. $body //= 'Internal Server Error';
  374. $self->INFO("$method 500 $fp");
  375. return _generic( $body, 500 );
  376. }
  377. sub unavailable {
  378. my ($self, $query, $body) = @_;
  379. $self->INFO("$query->{method} 503 $query->{fullpath}");
  380. $body //= 'Service Unavailable';
  381. return _generic( $body, 503 );
  382. }
  383. my $cur_query = {};
  384. sub app {
  385. my $self = shift;
  386. return eval { _app($self, @_) } || do {
  387. my $env = shift;
  388. $env->{'psgi.errors'}->print($@) if $env->{'psgi.errors'};
  389. # Redact the stack trace past line 1, it usually has things which should not be shown
  390. $cur_query->{message} = $@;
  391. $cur_query->{message} =~ s/\n.*//g if $cur_query->{message} && !$debug;
  392. return $self->error($cur_query);
  393. };
  394. }
  395. my %etags;
  396. sub _app {
  397. my $self = shift;
  398. # Start the server timing clock
  399. my $start = [gettimeofday];
  400. $cur_query = {};
  401. # Don't make things group read/write
  402. umask 0007;
  403. my $env = shift;
  404. $self->{filehandle} = $env->{'psgix.io'} // *STDOUT;
  405. # Setup the unique ID for the request
  406. $env->{REQUEST_ID} = $self->request_id(1);
  407. # Discard the path used in the log, it's too long and enough 4xx error code = ban
  408. return $self->toolong({ method => $env->{REQUEST_METHOD}, fullpath => '...' }) if length( $env->{REQUEST_URI} ) > 2048;
  409. # Various stuff important for logging requests
  410. my $domain = $env->{HTTP_X_FORWARDED_HOST} || $env->{HTTP_HOST} // eval { Sys::Hostname::hostname() };
  411. my $path = $env->{PATH_INFO};
  412. my $port = $env->{HTTP_X_FORWARDED_PORT} // $env->{HTTP_PORT};
  413. my $pport = defined $port ? ":$port" : "";
  414. my $scheme = $env->{'psgi.url_scheme'} // 'http';
  415. my $method = $env->{REQUEST_METHOD};
  416. # It's important that we log what the user ACTUALLY requested rather than the rewritten path later on.
  417. my $fullpath = "$scheme://$domain$pport$path";
  418. # So we can log it for fail2ban
  419. my $ip = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR};
  420. # set the referer & ua to go into DB logs, but not logs in general.
  421. # The referer/ua largely has no importance beyond being a proto bug report for log messages.
  422. my $referer = $env->{HTTP_REFERER};
  423. my $ua = $env->{HTTP_UA};
  424. $cur_query = {
  425. route => $path,
  426. fullpath => $path,
  427. method => $method,
  428. ip => $ip,
  429. ua => $ua,
  430. referer => $referer,
  431. };
  432. # Support aliased paths
  433. my $aliases = $self->{aliases};
  434. $path = $aliases->{$path} if exists $aliases->{$path};
  435. # Check eTags. If we don't know about it, just assume it's good and lazily fill the cache
  436. # XXX yes, this allows cache poisoning...but only for logged in users!
  437. # This also needs to be IN DB so that we coordinate properly across forks
  438. if ( $env->{HTTP_IF_NONE_MATCH} ) {
  439. $self->INFO("$env->{REQUEST_METHOD} 304 $fullpath");
  440. return [ 304, [], [''] ] if $env->{HTTP_IF_NONE_MATCH} eq ( $etags{ $env->{REQUEST_URI} } || '' );
  441. $etags{ $env->{REQUEST_URI} } = $env->{HTTP_IF_NONE_MATCH} unless exists $etags{ $env->{REQUEST_URI} };
  442. }
  443. # TODO: Actually do something with the language passed to the renderer
  444. my $lang = $env->{HTTP_ACCEPT_LANGUAGE};
  445. #TODO: Actually do something with the acceptable output formats in the renderer
  446. my $accept = $env->{HTTP_ACCEPT};
  447. my $last_fetch = 0;
  448. if ( $env->{HTTP_IF_MODIFIED_SINCE} ) {
  449. $last_fetch = DateTime::Format::HTTP->parse_datetime( $env->{HTTP_IF_MODIFIED_SINCE} )->epoch();
  450. }
  451. # Figure out if we want compression or not
  452. my $alist = $env->{HTTP_ACCEPT_ENCODING} || '';
  453. $alist =~ s/\s//g;
  454. my @accept_encodings;
  455. @accept_encodings = split( /,/, $alist );
  456. my $deflate = grep { 'gzip' eq $_ } @accept_encodings;
  457. # Set the IP of the request so we can fail2ban
  458. $self->{ip} = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR} || $self->{ip};
  459. # Make sure this works further down the line
  460. $env->{REMOTE_ADDR} = $env->{HTTP_X_FORWARDED_FOR} if $env->{HTTP_X_FORWARDED_FOR};
  461. my $streaming = $env->{'psgi.streaming'};
  462. # If we have an actual route, just use it.
  463. my $r = $self->routes;
  464. my $route_index = List::Util::first { ($r->[$_] // '') eq $path } 0..scalar(@$r);
  465. my $route_actual;
  466. $route_actual = $r->[$route_index+1] if defined($route_index);
  467. # Might be a regexed route. Sort reversed so we try the longest routes first.
  468. if (!$route_actual && @$r) {
  469. my $matched = List::Util::first {
  470. # Here's where you want to use Regexp::Debugger in the context of call.pl to debug routes... EX:
  471. # perl ./call.pl GET /path/to/route
  472. #print $r->[$_]."\n";
  473. $path =~ m/^$r->[$_]$/;
  474. } 0..scalar(@$r)-1;
  475. $route_actual = $r->[$matched+1] if defined($matched);
  476. }
  477. return $self->route( $route_actual, $env, $fullpath, $path, $start, $last_fetch, $deflate, $domain, $port ) if $route_actual;
  478. # Do a case insensitive match, because osx and windows
  479. my $file_possible = $self->mangle_filename("www/$path");
  480. my $file_actual = $file_possible if -f $file_possible;
  481. # Dirindices
  482. if (-d $file_possible) {
  483. foreach my $index (@{$self->indices}) {
  484. next if $file_actual;
  485. my $dirindex = $self->mangle_filename("www$path/$index");
  486. $dirindex =~ s|//|/|g;
  487. $file_actual = $dirindex if -f $dirindex;
  488. }
  489. }
  490. my $file_is_cgi = appears_executable($file_actual);
  491. return $self->cgi( $env, $fullpath, $file_actual, $last_fetch, $deflate) if $file_is_cgi;
  492. $self->INFO("Attempting to serve $fullpath [".($file_possible // "")."]");
  493. my @ranges = parse_ranges($env);
  494. return $self->serve( $fullpath, $file_actual, $start, $streaming, \@ranges, $last_fetch, $deflate ) if $file_actual;
  495. return $self->notfound($cur_query);
  496. }
  497. sub mangle_filename {
  498. my ($self,$path) = @_;
  499. # XXX yes, you can have case sensitive OSX, but basically nobody enables that ever
  500. $path = lc($path) if List::Util::any { $^O eq $_ } qw{darwin MSWin32 dos};
  501. $self->DEBUG("Path mangled to $path");
  502. return $path;
  503. }
  504. sub parse_ranges {
  505. my $env = shift;
  506. # Handle HTTP range/streaming requests
  507. my $range = $env->{HTTP_RANGE} || "bytes=0-" if $env->{HTTP_RANGE} || $env->{HTTP_IF_RANGE};
  508. my @ranges;
  509. if ($range) {
  510. $range =~ s/bytes=//g;
  511. push(
  512. @ranges,
  513. map {
  514. [ split( /-/, $_ ) ];
  515. } split( /,/, $range )
  516. );
  517. }
  518. return @ranges;
  519. }
  520. my @executable_extensions = qw{cgi sh exe pl php py};
  521. sub appears_executable {
  522. my $subj = shift;
  523. # Definitely not executable if it does not exist!
  524. return 0 unless $subj;
  525. return 0 unless -x $subj;
  526. my ($extension) = $subj =~ m/\.(\S+)$/;
  527. return 1 if !$extension;
  528. return 1 if grep { $extension eq $_ } @executable_extensions;
  529. return 0;
  530. }
  531. # Handle actual routes
  532. sub route {
  533. my ($self, $route, $env, $fullpath, $path, $start, $last_fetch, $deflate, $domain, $port ) = @_;
  534. $self->DEBUG('Executing route '.$route->{pattern});
  535. my $content_type = $env->{CONTENT_TYPE} || 'text/html'; # If not set, that's the assumption.
  536. # It is the responsibility of each route to respond to HEAD requests correctly
  537. return $self->badrequest($cur_query) unless List::Util::any { $_ eq $env->{REQUEST_METHOD} } grep { defined $_ } ('HEAD', $route->{method});
  538. my $callback;
  539. if (exists $route->{callbacks}{'*'}) {
  540. $callback = $route->{callbacks}{'*'};
  541. } else {
  542. return $self->badrequest($cur_query) unless exists $route->{callbacks}{$content_type};
  543. $callback = $route->{callbacks}{$content_type};
  544. }
  545. # Provide static renders if available.
  546. # TODO allow routes to signal back up to us if they want to go static.
  547. my $streaming = $env->{'psgi.streaming'};
  548. return $self->static( $fullpath, "$fullpath.z", $start, $streaming ) if -f "statics/$fullpath.z" && $deflate;
  549. return $self->static( $fullpath, $fullpath, $start, $streaming ) if -f "statics/$fullpath";
  550. # Build the query data for passing to a route.
  551. # GET, POST, URI captures, then explicit data overrides.
  552. my $query = $self->extract_query($path, $route, $env);
  553. # allow this stuff to survive down to the end of some routes
  554. $query->{last_fetched} = $last_fetch;
  555. $query->{deflate} = $deflate;
  556. $query->{streaming} = $streaming;
  557. my @ranges = parse_ranges($env);
  558. $query->{ranges} = \@ranges;
  559. $query->{start} = $start;
  560. # Put things to tv_interval in here for Server-Timing
  561. $query->{fullpath} = $fullpath;
  562. $query->{method} = $route->{method};
  563. $query->{route} = $path;
  564. $query->{cookies} = $env->{HTTP_COOKIE};
  565. $query->{dnt} = $env->{HTTP_DNT};
  566. $query->{nosellinfo} = $env->{HTTP_SEC_GPC};
  567. $query->{port} = $port;
  568. $query->{scheme} = $env->{'psgi.url_scheme'} // 'http';
  569. $query->{method} = $env->{REQUEST_METHOD};
  570. $query->{lang} = $env->{HTTP_ACCEPT_LANGUAGE};
  571. $query->{accept} = $env->{HTTP_ACCEPT};
  572. $query->{has_query} = !!$env->{QUERY_STRING};
  573. $query->{domain} = $domain;
  574. $query->{dispatcher} = $route;
  575. $query->{ip} = $cur_query->{ip};
  576. $query->{ua} = $cur_query->{ua};
  577. $query->{referer} = $cur_query->{referer};
  578. # This allows for better error handlers if we die in the route.
  579. $cur_query = $query;
  580. # Setup the CGI vars they expect IF requested
  581. local %ENV = (%ENV, CGI::Emulate::PSGI->emulate_environment($env)) if $route->{env};
  582. {
  583. my $output = $callback->($self, $query);
  584. # If it's streaming, just hand it off
  585. return $output if ref $output eq 'CODE';
  586. die "$fullpath returned no or malformed data!" unless ref $output eq 'ARRAY' && @$output == 3;
  587. my $pport = defined $query->{port} ? ":$query->{port}" : "";
  588. my %headers = @{$output->[1]};
  589. my $bytes = $headers{'Content-Length'} // '?';
  590. $self->INFO("$env->{REQUEST_METHOD} $output->[0] $bytes $fullpath");
  591. # Append server-timing headers if they aren't present
  592. my $tot = tv_interval($start) * 1000;
  593. push( @{ $output->[1] }, 'Server-Timing' => "app;dur=$tot" ) unless List::Util::any { $_ eq 'Server-Timing' } @{ $output->[1] };
  594. return $output;
  595. }
  596. }
  597. sub extract_query {
  598. my ($self, $path, $route, $env) = @_;
  599. my $query = URL::Encode::url_params_mixed( $env->{QUERY_STRING} ) if $env->{QUERY_STRING};
  600. #Actually parse the POSTDATA and dump it into the QUERY object if this is a POST
  601. if ( $env->{REQUEST_METHOD} eq 'POST' ) {
  602. my $body = HTTP::Body->new( $env->{CONTENT_TYPE}, $env->{CONTENT_LENGTH} );
  603. while ( $env->{'psgi.input'}->read( my $buf, $CHUNK_SIZE ) ) {
  604. $body->add($buf);
  605. }
  606. @$query{ keys( %{ $body->param } ) } = values( %{ $body->param } );
  607. @$query{ keys( %{ $body->upload } ) } = values( %{ $body->upload } );
  608. }
  609. if (ref $route->{captures} eq 'ARRAY') {
  610. my @captures = $path =~ m/^$route->{pattern}$/;
  611. @$query{@{$route->{captures}}} = @captures;
  612. }
  613. if (ref $route->{data} eq 'HASH') {
  614. @$query{keys(%{$route->{data}})} = values(%{$route->{data}});
  615. }
  616. return $query;
  617. }
  618. # Read until we are done with headers, then pass off the filehandle to PSGI for streaming.
  619. # TODO support compression
  620. sub cgi {
  621. my ( $self, $env, $fullpath, $file_actual, $last_fetch, $deflate) = @_;
  622. $self->INFO("Handoff $fullpath to $file_actual for cgi exec");
  623. # Setup the CGI vars they expect
  624. local %ENV = (%ENV, CGI::Emulate::PSGI->emulate_environment($env));
  625. my $pid = open(my $out, '-|', "$file_actual");
  626. my ($code, $offset, %headers) = extract_headers($out, $last_fetch);
  627. $code //= 500;
  628. return sub {
  629. my $responder = shift;
  630. my $writer = $responder->( [ $code, [%headers] ] );
  631. while ( $out->read( my $buf, $CHUNK_SIZE ) ) {
  632. $writer->write($buf);
  633. }
  634. $writer->close;
  635. # Wait on the CGI script to do whatever it's doing after closing stdout
  636. close $out;
  637. waitpid($pid, 0);
  638. };
  639. }
  640. sub stream_raw_and_i_mean_it {
  641. my ($self, $data, $output, $last_fetch, $to_fork, $callback) = @_;
  642. return sub {
  643. my $responder = shift;
  644. };
  645. }
  646. sub stream_raw_http {
  647. my ($self, $data, $last_fetch, $to_fork, $callback, $error_handler) = @_;
  648. my $time_to_here = tv_interval($data->{start});
  649. $self->DEBUG("Routing took $time_to_here s");
  650. my $post_routing = [gettimeofday];
  651. my $fh = $self->{filehandle};
  652. # The CGIs you are executing here SHOULD NOT emit a status line.
  653. # Most apache CGIs you find don't anyways, so this is usually not a big deal.
  654. print $fh "HTTP/1.1 200 OK\n";
  655. # Emit our server-timing header.
  656. print $fh "Server-Timing: ".$self->build_server_timing($data)."\n"; #app;dur=".($time_to_here*1000)."\n";
  657. # We *must* fork because we can't rely on the child to close stdout.
  658. my $pid = _fork(sub {
  659. $self->DEBUG("Forking child took".(tv_interval($post_routing))." s");
  660. local $@;
  661. eval { $to_fork->() };
  662. $self->ERROR("Raw HTTTP streaming encountered exception: $@") if $@;
  663. if (ref $error_handler eq 'CODE') {
  664. local $@;
  665. eval { $error_handler->() };
  666. $self->ERROR("HTTP streaming error handler encountered exception: $@") if $@;
  667. }
  668. }, $fh);
  669. waitpid($pid, 0);
  670. close($fh);
  671. $self->DEBUG("Response written in ".(tv_interval($post_routing))." s");
  672. if (ref $callback eq 'CODE') {
  673. my $post_start = [gettimeofday];
  674. local $@;
  675. eval { $callback->() };
  676. $self->ERROR("Post-close callback encountered exception: $@") if $@;
  677. $self->DEBUG("Post-close callback took ".(tv_interval($post_start))." s");
  678. }
  679. exit 0;
  680. }
  681. sub _fork {
  682. my ($callback, $fh) = @_;
  683. select $fh;
  684. $|=1;
  685. my $pid = fork();
  686. die "Could not fork child" unless defined $pid;
  687. if ($pid == 0) {
  688. $callback->();
  689. exit 0;
  690. }
  691. select STDOUT;
  692. return $pid;
  693. }
  694. =head2 extract_headers($fh, $last_fetch)
  695. Extract the headers from a filehandle. Set the 'Last Modified' header appropriately based on the output of `stat` on the filehandle.
  696. 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.
  697. =cut
  698. sub extract_headers {
  699. my ($fh, $last_fetch, $is_ref) = @_;
  700. my $headers = '';
  701. # NOTE: this is relying on while advancing the file pointer
  702. while (<$fh>) {
  703. last if $_ eq "\n";
  704. $headers .= $_;
  705. }
  706. #XXX still a toctou
  707. my $offset = $fh->tell();
  708. my ( undef, undef, $status, undef, $headers_parsed ) = HTTP::Parser::XS::parse_http_response( "$headers\n", HEADERS_AS_HASHREF );
  709. my $code = $status // 200;
  710. $headers_parsed //= {};
  711. if (!$is_ref) {
  712. my $mt = ( stat($fh) )[9];
  713. my @gm = gmtime($mt);
  714. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  715. $code = $mt > $last_fetch ? $status : 304;
  716. $headers_parsed->{"Last-Modified"} = $now_string;
  717. }
  718. return ($code, $offset, %$headers_parsed);
  719. }
  720. sub static {
  721. my ( $self, $fullpath, $path, $start, $streaming, $last_fetch ) = @_;
  722. $self->DEBUG("Rendering static for $path");
  723. # XXX because of psgi I can't just vomit the file directly
  724. if ( open( my $fh, '<', "statics/$path" ) ) {
  725. my ($code, $offset, $headers_parsed) = (200, undef, {});
  726. ($code, $offset, %$headers_parsed) = extract_headers($fh, $last_fetch );
  727. # Append server-timing headers
  728. my $tot = tv_interval($start) * 1000;
  729. $headers_parsed->{'Server-Timing'} = "static;dur=$tot";
  730. #XXX uwsgi just opens the file *again* when we already have a filehandle if it has a path.
  731. # starman by comparison doesn't violate the principle of least astonishment here.
  732. # This is probably a performance optimization, but makes the kind of micromanagement I need to do inconvenient.
  733. # As such, we will just return a stream.
  734. $self->INFO("GET 200 $headers_parsed->{'Content-Length'} $fullpath");
  735. return sub {
  736. my $responder = shift;
  737. #push(@headers, 'Content-Length' => $sz);
  738. my $writer = $responder->( [ $code, [%$headers_parsed] ] );
  739. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  740. $writer->write($buf);
  741. }
  742. close $fh;
  743. $writer->close;
  744. }
  745. if $streaming;
  746. return [ $code, [%$headers_parsed], $fh ];
  747. }
  748. return $self->forbidden($self->{current_query});
  749. }
  750. sub get_config {
  751. $ENV{HOME} ||= Cwd::getcwd();
  752. my %options = (
  753. verbose => 0,
  754. custom_log => undef,
  755. routers => [],
  756. loggers => [],
  757. auth => undef,
  758. domain => '',
  759. user => '',
  760. binds => [],
  761. basedir => $ENV{'HOME'},
  762. );
  763. my $config_file = "$ENV{HOME}/.tpsgi.ini";
  764. if (-f $config_file) {
  765. my $conf = Config::Simple->new($config_file);
  766. my %config;
  767. %config = %{$conf->param(-block => 'default')} if $conf;
  768. # Merge the configuration with the options
  769. foreach my $opt (keys(%options)) {
  770. if ( ref $options{$opt} eq 'ARRAY' ) {
  771. next unless exists $config{$opt};
  772. my @arrayed = ref $config{$opt} eq 'ARRAY' ? @{$config{$opt}} : ($config{$opt});
  773. push(@{$options{$opt}}, @arrayed);
  774. next;
  775. }
  776. $options{$opt} = $config{$opt} if exists $config{$opt};
  777. }
  778. }
  779. return %options;
  780. }
  781. # Convenience method to keep track of server-timing
  782. sub checkpoint {
  783. my ($self, $data, $name) = @_;
  784. $data->{checkpoints} //= [[ start => $data->{start} ]];
  785. push(@{$data->{checkpoints}}, [ $name => [gettimeofday] ]);
  786. }
  787. sub build_server_timing {
  788. my ($self, $data) = @_;
  789. my @st;
  790. my $last_interval;
  791. foreach my $checkpoint (@{$data->{checkpoints}}) {
  792. if ($last_interval) {
  793. my $dur = tv_interval($last_interval, $checkpoint->[1]) * 1000;
  794. push(@st, "$checkpoint->[0];dur=$dur");
  795. }
  796. $last_interval = $checkpoint->[1];
  797. }
  798. my $tot = tv_interval($data->{start}) * 1000;
  799. push(@st, "tot;dur=$tot");
  800. delete $data->{checkpoints};
  801. return join(', ', @st);
  802. }
  803. 1;