TPSGI.pm 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221
  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. my $generic_handler;
  73. sub request_id {
  74. my ( $self, $regenerate ) = @_;
  75. return $self->{uuid} if $self->{uuid} && !$regenerate;
  76. $self->{uuid} = UUID::uuid();
  77. return $self->{uuid};
  78. }
  79. sub log {
  80. my $self = shift;
  81. state $log;
  82. return $log if $log;
  83. my $LOGNAME = abs_path('.') . '/log/tpsgi.log';
  84. $LOGNAME = $self->{custom_log} if $self->{custom_log};
  85. my $LOGDIR = dirname($LOGNAME);
  86. File::Path::make_path($LOGDIR) unless -d $LOGDIR;
  87. File::Touch::touch($LOGNAME) unless -f $LOGNAME;
  88. my $LEVEL = $self->{verbose} ? 'debug' : 'info';
  89. # By default only log requests & warnings.
  90. # Otherwise emit debug messages.
  91. my $rotate = Log::Dispatch::FileRotate->new(
  92. name => 'tcms',
  93. filename => $LOGNAME,
  94. min_level => $LEVEL,
  95. 'mode' => 'append',
  96. size => 10 * 1024 * 1024,
  97. max => 6,
  98. );
  99. # Only send fatal events/errors to stdout
  100. my $screen = Log::Dispatch::Screen->new(
  101. name => 'screen',
  102. min_level => $LEVEL,
  103. );
  104. $log = Log::Dispatch->new();
  105. $log->add($rotate);
  106. $log->add($screen);
  107. foreach my $logger ( @{ $self->{loggers} } ) {
  108. $log->add( $logger->new( min_level => $LEVEL, log_dir => $LOGDIR ) );
  109. }
  110. $log->info( $self->_log("Opening Log $LOGNAME at $LEVEL level") );
  111. return $log;
  112. }
  113. sub _log {
  114. my ( $self, $msg ) = @_;
  115. $msg //= "No message passed. This is almost certainly a bug. ";
  116. #XXX Log lines must start as an ISO8601 date, anything else breaks fail2ban's beautiful mind
  117. my $tstamp = POSIX::strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
  118. my $uuid = $self->request_id();
  119. return "[Worker $$] {Request $uuid} $tstamp : $self->{ip} $msg\n";
  120. }
  121. # Logger short cuts
  122. sub DEBUG {
  123. my $self = shift;
  124. $self->log->debug( $self->_log(shift) );
  125. }
  126. sub INFO {
  127. my $self = shift;
  128. $self->log->info( $self->_log(shift) );
  129. }
  130. sub NOTE {
  131. my $self = shift;
  132. $self->log->notice( $self->_log(shift) );
  133. }
  134. sub WARN {
  135. my $self = shift;
  136. $self->log->warning( $self->_log(shift) );
  137. }
  138. sub ERROR {
  139. my $self = shift;
  140. $self->log->error( $self->_log(shift) );
  141. }
  142. sub CRIT {
  143. my $self = shift;
  144. $self->log->critical( $self->_log(shift) );
  145. }
  146. sub ALERT {
  147. my $self = shift;
  148. $self->log->alert( $self->_log(shift) );
  149. }
  150. sub EMERG {
  151. my $self = shift;
  152. $self->log->emergency( $self->_log(shift) );
  153. }
  154. sub FATAL {
  155. my $self = shift;
  156. $self->log->log_and_die( level => 'emergency', message => $self->_log(shift) );
  157. }
  158. =head1 new(%options)
  159. Options are:
  160. custom_log
  161. verbose
  162. routers
  163. loggers
  164. auth
  165. =cut
  166. sub new {
  167. my ( $class, %options ) = @_;
  168. # Refuse to run as wrong user, all config will be borked otherwise
  169. my $pname = getpwuid($>);
  170. die "Must run as configured user (got: $pname, want: $options{user})!" unless $pname eq ( $options{user} // '' );
  171. my $self = bless( \%options, $class );
  172. $self->{ip} = '0.0.0.0';
  173. my @routes;
  174. my %aliases;
  175. # XXX TODO make these routes able to be fully qualified namespaces (::)!
  176. no strict 'refs';
  177. foreach my $route ( @{ $self->{routers} } ) {
  178. die "No such routing module $route" unless -f $route;
  179. # The router needs to exist and have nonzero numbers of routes.
  180. my ($package) = basename($route) =~ m/(\S+)\.pm$/;
  181. my $r = "$package\:\:routes";
  182. my $a = "$package\:\:aliases";
  183. my $libdir = dirname($route);
  184. push( @INC, $libdir );
  185. local $@;
  186. $self->DEBUG("require $route");
  187. my $success = eval { require $route; 1; };
  188. if ($success) {
  189. my $pkg_routes = *$r{ARRAY};
  190. for ( my $i = 0; $i < @$pkg_routes; $i += 2 ) {
  191. $self->DEBUG("Registered route $pkg_routes->[$i]");
  192. }
  193. push( @routes, @$pkg_routes );
  194. my $pkg_aliases = *$a{HASH};
  195. foreach my $al ( keys(%$pkg_aliases) ) {
  196. $self->DEBUG("aliased route $al to $pkg_aliases->{$al}");
  197. }
  198. @aliases{ keys(%$pkg_aliases) } = values(%$pkg_aliases);
  199. # First-come first-served error template overrides
  200. $generic_handler = "$package\:\:generic_route";
  201. }
  202. else {
  203. die "Could not load $route!\n$@\n";
  204. }
  205. }
  206. use strict 'refs';
  207. # Set the pattern used to discover the route if needed later.
  208. for ( my $i = 0; $i < scalar(@routes); $i += 2 ) {
  209. $routes[ $i + 1 ]{pattern} = $routes[$i];
  210. }
  211. $self->{indices} //= [];
  212. $self->{indices} = [ @{ $self->{indices} }, qw{index.html index.htm index.cgi} ];
  213. $self->{routes} = \@routes;
  214. $self->{aliases} = \%aliases;
  215. $self->{callbacks} = [];
  216. return $self;
  217. }
  218. sub indices {
  219. my $self = shift;
  220. return $self->{indices};
  221. }
  222. sub routes {
  223. my $self = shift;
  224. return $self->{routes};
  225. }
  226. =head2 serve
  227. Serve a file, with options to stream and cache the output.
  228. =cut
  229. sub serve {
  230. my ( $self, $fullpath, $path, $start, $streaming, $ranges, $last_fetch, $deflate ) = @_;
  231. $last_fetch ||= 0;
  232. $deflate ||= 0;
  233. my $mf = Mojo::File->new($path);
  234. my $ext = '.' . $mf->extname();
  235. my $ft;
  236. if ($ext) {
  237. $ft = Plack::MIME->mime_type($ext) if $ext;
  238. $ft ||= $extra_types{$ext} if exists $extra_types{$ext};
  239. }
  240. $ft ||= $content_types{text};
  241. my @headers = ( $ct => $ft );
  242. #TODO use static Cache-Control for everything but JS/CSS?
  243. push( @headers, 'Cache-control' => $cache_control{revalidate} );
  244. push( @headers, 'Accept-Ranges' => 'bytes' );
  245. $self->DEBUG("FETCH $path");
  246. my $mt = ( stat($path) )[9];
  247. my $sz = ( stat(_) )[7];
  248. my @gm = gmtime($mt);
  249. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  250. my $code = $mt > $last_fetch ? 200 : 304;
  251. push( @headers, "Last-Modified" => $now_string );
  252. push( @headers, 'Vary' => 'Accept-Encoding' );
  253. if ( open( my $fh, '<', $path ) ) {
  254. return $self->_range( $fullpath, $fh, $ranges, $sz, @headers ) if @$ranges && $streaming;
  255. # Transfer-encoding: chunked
  256. return sub {
  257. my $responder = shift;
  258. push( @headers, 'Content-Length' => $sz );
  259. my $writer = $responder->( [ $code, \@headers ] );
  260. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  261. $writer->write($buf);
  262. }
  263. close $fh;
  264. $writer->close;
  265. }
  266. if $streaming && $sz > $CHUNK_SIZE;
  267. #Return data in the event the caller does not support deflate
  268. if ( !$deflate ) {
  269. push( @headers, "Content-Length" => $sz );
  270. # Append server-timing headers
  271. my $tot = tv_interval($start) * 1000;
  272. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  273. return [ $code, \@headers, $fh ];
  274. }
  275. #Compress everything less than 1MB
  276. push( @headers, "Content-Encoding" => "gzip" );
  277. my $dfh;
  278. IO::Compress::Gzip::gzip( $fh => \$dfh );
  279. print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
  280. push( @headers, "Content-Length" => length($dfh) );
  281. $self->INFO("GET 200 $fullpath");
  282. # Append server-timing headers
  283. my $tot = tv_interval($start) * 1000;
  284. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  285. return [ $code, \@headers, [$dfh] ];
  286. }
  287. $self->INFO("GET 403 $fullpath");
  288. return [ 403, [ $ct => $content_types{text} ], ["Forbidden"] ];
  289. }
  290. sub _range {
  291. my ( $self, $fullpath, $fh, $ranges, $sz, %headers ) = @_;
  292. # Set mode
  293. my $primary_ct = "Content-Type: $headers{'Content-type'}";
  294. my $is_multipart = scalar(@$ranges) > 1;
  295. if ($is_multipart) {
  296. $headers{'Content-type'} = "multipart/byteranges; boundary=$CHUNK_SEP";
  297. }
  298. my $code = 206;
  299. my $fc = '';
  300. # Calculate the content-length up-front. We have to fix unspecified lengths first, and reject bad requests.
  301. foreach my $range (@$ranges) {
  302. $range->[1] //= $sz - 1;
  303. $self->INFO("GET 416 $fullpath");
  304. return [ 416, [%headers], ["Requested range not satisfiable"] ] if $range->[0] > $sz || $range->[0] < 0 || $range->[1] < 0 || $range->[0] > $range->[1];
  305. }
  306. $headers{'Content-Length'} = List::Util::sum( map { my $arr = $_; $arr->[1] + 1, -$arr->[0] } @$ranges );
  307. #XXX Add the entity header lengths to the value - should hash-ify this to DRY
  308. if ($is_multipart) {
  309. foreach my $range (@$ranges) {
  310. $headers{'Content-Length'} += length("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n");
  311. $fc = "\n";
  312. }
  313. $headers{'Content-Length'} += length("\n--$CHUNK_SEP\--\n");
  314. $fc = '';
  315. }
  316. return sub {
  317. my $responder = shift;
  318. my $writer;
  319. foreach my $range (@$ranges) {
  320. $headers{'Content-Range'} = "bytes $range->[0]-$range->[1]/$sz" unless $is_multipart;
  321. $writer //= $responder->( [ $code, [%headers] ] );
  322. $writer->write("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n") if $is_multipart;
  323. $fc = "\n";
  324. my $len = List::Util::min( $sz, $range->[1] + 1 ) - $range->[0];
  325. $fh->seek( $range->[0], 0 );
  326. while ($len) {
  327. $fh->read( my $buf, List::Util::min( $len, $CHUNK_SIZE ) );
  328. $writer->write($buf);
  329. # Adjust for amount written
  330. $len = List::Util::max( $len - $CHUNK_SIZE, 0 );
  331. }
  332. }
  333. $fh->close();
  334. $writer->write("\n--$CHUNK_SEP\--\n") if $is_multipart;
  335. $writer->close;
  336. };
  337. }
  338. sub _generic {
  339. my ( $type, $code, $query ) = @_;
  340. if ($generic_handler) {
  341. my $rname = "/$code";
  342. my $title = $type;
  343. no strict 'refs';
  344. return $generic_handler->( $rname, $code, $title, $query);
  345. }
  346. $type .= Carp::longmess() if $debug;
  347. return [ $code, [ $ct => $content_types{html} ], ["$type"] ];
  348. }
  349. =head2 redirect, redirect_permanent, see_also
  350. Redirects to the provided page.
  351. =cut
  352. sub redirect {
  353. my ( $self, $to ) = @_;
  354. $self->INFO("redirect: $to");
  355. return [ 302, [ "Location" => $to, "Content-Length" => 0 ], [''] ];
  356. }
  357. sub redirect_permanent {
  358. my ( $self, $to ) = @_;
  359. $self->INFO("permanent redirect: $to");
  360. return [ 301, [ "Location" => $to, "Content-Length" => 0 ], [''] ];
  361. }
  362. sub see_also {
  363. my ( $self, $to ) = @_;
  364. $self->INFO("see also: $to");
  365. return [ 303, [ "Location" => $to, "Content-Length" => 0 ], [''] ];
  366. }
  367. =head2 notfound, forbidden, badrequest, toolong, error
  368. If you need to return these HTTP errors, return these within a route:
  369. sub route {
  370. my ($self, $query) = @_;
  371. ...
  372. return $self->notfound($query);
  373. }
  374. =cut
  375. sub notfound {
  376. my ( $self, $query, $body ) = @_;
  377. $self->INFO("$query->{method} 404 $query->{fullpath}");
  378. $body //= 'Not Found';
  379. return _generic( $body, 404 );
  380. }
  381. sub forbidden {
  382. my ( $self, $query, $body ) = @_;
  383. $self->INFO("$query->{method} 403 $query->{fullpath}");
  384. $body //= 'Forbidden';
  385. return _generic( $body, 403 );
  386. }
  387. sub badrequest {
  388. my ( $self, $query, $body ) = @_;
  389. $self->INFO("$query->{method} 400 $query->{fullpath}");
  390. $body //= 'Bad Request';
  391. return _generic( $body, 400 );
  392. }
  393. sub toolong {
  394. my ( $self, $query, $body ) = @_;
  395. $self->INFO("$query->{method} 419 $query->{fullpath}");
  396. $body //= 'URI too long';
  397. return _generic( $body, 419 );
  398. }
  399. sub error {
  400. my ( $self, $query, $body ) = @_;
  401. my $method = $query->{method} // "?";
  402. my $fp = $query->{fullpath} // "?";
  403. $body //= 'Internal Server Error';
  404. $self->INFO("$method 500 $fp");
  405. return _generic( $body, 500 );
  406. }
  407. sub unavailable {
  408. my ( $self, $query, $body ) = @_;
  409. $self->INFO("$query->{method} 503 $query->{fullpath}");
  410. $body //= 'Service Unavailable';
  411. return _generic( $body, 503 );
  412. }
  413. my $cur_query = {};
  414. sub app {
  415. my $self = shift;
  416. return eval { _app( $self, @_ ) } || do {
  417. my $env = shift;
  418. $env->{'psgi.errors'}->print($@) if $env->{'psgi.errors'};
  419. # Redact the stack trace past line 1, it usually has things which should not be shown
  420. $cur_query->{message} = $@;
  421. $cur_query->{message} =~ s/\n.*//g if $cur_query->{message} && !$debug;
  422. return $self->error($cur_query);
  423. };
  424. }
  425. my %etags;
  426. sub _app {
  427. my $self = shift;
  428. # Start the server timing clock
  429. my $start = [gettimeofday];
  430. $cur_query = {};
  431. # Don't make things group read/write
  432. umask 0007;
  433. my $env = shift;
  434. $self->{filehandle} = $env->{'psgix.io'} // *STDOUT;
  435. # Setup the unique ID for the request
  436. $env->{REQUEST_ID} = $self->request_id(1);
  437. # Discard the path used in the log, it's too long and enough 4xx error code = ban
  438. return $self->toolong( { method => $env->{REQUEST_METHOD}, fullpath => '...' } ) if length( $env->{REQUEST_URI} ) > 2048;
  439. # Various stuff important for logging requests
  440. my $domain = $env->{HTTP_X_FORWARDED_HOST} || $env->{HTTP_HOST} // eval { Sys::Hostname::hostname() };
  441. my $path = $env->{PATH_INFO};
  442. # de-pooplicate the path
  443. $path =~ s|//|/|g;
  444. my $port = $env->{HTTP_X_FORWARDED_PORT} // $env->{HTTP_PORT};
  445. my $pport = defined $port ? ":$port" : "";
  446. my $scheme = $env->{'psgi.url_scheme'} // 'http';
  447. my $method = $env->{REQUEST_METHOD};
  448. # It's important that we log what the user ACTUALLY requested rather than the rewritten path later on.
  449. my $fullpath = "$scheme://$domain$pport$path";
  450. # So we can log it for fail2ban
  451. my $ip = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR};
  452. # set the referer & ua to go into DB logs, but not logs in general.
  453. # The referer/ua largely has no importance beyond being a proto bug report for log messages.
  454. my $referer = $env->{HTTP_REFERER};
  455. my $ua = $env->{HTTP_UA};
  456. $cur_query = {
  457. route => $path,
  458. fullpath => $path,
  459. method => $method,
  460. ip => $ip,
  461. ua => $ua,
  462. referer => $referer,
  463. };
  464. # Disallow any paths that are naughty - this appears to be done by starman automatically.
  465. #if ( index($path, '..') != -1 ) {
  466. # return $self->forbidden($cur_query);
  467. #}
  468. # Support aliased paths
  469. my $aliases = $self->{aliases};
  470. $path = $aliases->{$path} if exists $aliases->{$path};
  471. # Check eTags. If we don't know about it, just assume it's good and lazily fill the cache
  472. # XXX yes, this allows cache poisoning...but only for logged in users!
  473. # This also needs to be IN DB so that we coordinate properly across forks
  474. if ( $env->{HTTP_IF_NONE_MATCH} ) {
  475. $self->INFO("$env->{REQUEST_METHOD} 304 $fullpath");
  476. return [ 304, [], [''] ] if $env->{HTTP_IF_NONE_MATCH} eq ( $etags{ $env->{REQUEST_URI} } || '' );
  477. $etags{ $env->{REQUEST_URI} } = $env->{HTTP_IF_NONE_MATCH} unless exists $etags{ $env->{REQUEST_URI} };
  478. }
  479. # TODO: Actually do something with the language passed to the renderer
  480. my $lang = $env->{HTTP_ACCEPT_LANGUAGE};
  481. #TODO: Actually do something with the acceptable output formats in the renderer
  482. my $accept = $env->{HTTP_ACCEPT};
  483. my $last_fetch = 0;
  484. if ( $env->{HTTP_IF_MODIFIED_SINCE} ) {
  485. $last_fetch = DateTime::Format::HTTP->parse_datetime( $env->{HTTP_IF_MODIFIED_SINCE} )->epoch();
  486. }
  487. # Figure out if we want compression or not
  488. my $alist = $env->{HTTP_ACCEPT_ENCODING} || '';
  489. $alist =~ s/\s//g;
  490. my @accept_encodings;
  491. @accept_encodings = split( /,/, $alist );
  492. my $deflate = grep { 'gzip' eq $_ } @accept_encodings;
  493. # Set the IP of the request so we can fail2ban
  494. $self->{ip} = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR} || $self->{ip};
  495. # Make sure this works further down the line
  496. $env->{REMOTE_ADDR} = $env->{HTTP_X_FORWARDED_FOR} if $env->{HTTP_X_FORWARDED_FOR};
  497. my $streaming = $env->{'psgi.streaming'};
  498. # If we have an actual route, just use it.
  499. my $r = $self->routes;
  500. my $route_index = List::Util::first { ( $r->[$_] // '' ) eq $path } 0 .. scalar(@$r);
  501. my $route_actual;
  502. $route_actual = $r->[ $route_index + 1 ] if defined($route_index);
  503. # Might be a regexed route. Sort reversed so we try the longest routes first.
  504. if ( !$route_actual && @$r ) {
  505. my $matched = List::Util::first {
  506. # Here's where you want to use Regexp::Debugger in the context of call.pl to debug routes... EX:
  507. # perl ./call.pl GET /path/to/route
  508. #print $r->[$_]."\n";
  509. $path =~ m/^$r->[$_]$/;
  510. }
  511. 0 .. scalar(@$r) - 1;
  512. $route_actual = $r->[ $matched + 1 ] if defined($matched);
  513. }
  514. return $self->route( $route_actual, $env, $fullpath, $path, $start, $last_fetch, $deflate, $domain, $port ) if $route_actual;
  515. # Do a case insensitive match, because osx and windows
  516. my $file_possible = $self->mangle_filename("www/$path");
  517. my $file_actual = $file_possible if -f $file_possible;
  518. # Dirindices
  519. if ( -d $file_possible ) {
  520. foreach my $index ( @{ $self->indices } ) {
  521. next if $file_actual;
  522. my $dirindex = $self->mangle_filename("www$path/$index");
  523. $dirindex =~ s|//|/|g;
  524. $file_actual = $dirindex if -f $dirindex;
  525. }
  526. }
  527. my $file_is_cgi = appears_executable($file_actual);
  528. return $self->cgi( $env, $fullpath, $file_actual, $last_fetch, $deflate ) if $file_is_cgi;
  529. $self->INFO( "Attempting to serve $fullpath [" . ( $file_possible // "" ) . "]" );
  530. my @ranges = parse_ranges($env);
  531. return $self->serve( $fullpath, $file_actual, $start, $streaming, \@ranges, $last_fetch, $deflate ) if $file_actual;
  532. return $self->notfound($cur_query);
  533. }
  534. sub mangle_filename {
  535. my ( $self, $path ) = @_;
  536. # XXX yes, you can have case sensitive OSX, but basically nobody enables that ever
  537. $path = lc($path) if List::Util::any { $^O eq $_ } qw{darwin MSWin32 dos};
  538. $self->DEBUG("Path mangled to $path");
  539. return $path;
  540. }
  541. sub parse_ranges {
  542. my $env = shift;
  543. # Handle HTTP range/streaming requests
  544. my $range = $env->{HTTP_RANGE} || "bytes=0-" if $env->{HTTP_RANGE} || $env->{HTTP_IF_RANGE};
  545. my @ranges;
  546. if ($range) {
  547. $range =~ s/bytes=//g;
  548. push(
  549. @ranges,
  550. map { [ split( /-/, $_ ) ]; } split( /,/, $range )
  551. );
  552. }
  553. return @ranges;
  554. }
  555. my @executable_extensions = qw{cgi sh exe pl php py};
  556. sub appears_executable {
  557. my $subj = shift;
  558. # Definitely not executable if it does not exist!
  559. return 0 unless $subj;
  560. return 0 unless -x $subj;
  561. my ($extension) = $subj =~ m/\.(\S+)$/;
  562. return 1 if !$extension;
  563. return 1 if grep { $extension eq $_ } @executable_extensions;
  564. return 0;
  565. }
  566. # Handle actual routes
  567. sub route {
  568. my ( $self, $route, $env, $fullpath, $path, $start, $last_fetch, $deflate, $domain, $port ) = @_;
  569. $self->DEBUG( 'Executing route ' . $route->{pattern} );
  570. my $content_type = $env->{CONTENT_TYPE} || 'text/html'; # If not set, that's the assumption.
  571. # It is the responsibility of each route to respond to HEAD requests correctly
  572. return $self->badrequest($cur_query) unless List::Util::any { $_ eq $env->{REQUEST_METHOD} } grep { defined $_ } ( 'HEAD', $route->{method} );
  573. my $callback;
  574. if ( exists $route->{callbacks}{'*'} ) {
  575. $callback = $route->{callbacks}{'*'};
  576. }
  577. else {
  578. return $self->badrequest($cur_query) unless exists $route->{callbacks}{$content_type};
  579. $callback = $route->{callbacks}{$content_type};
  580. }
  581. my $streaming = $env->{'psgi.streaming'};
  582. # Build the query data for passing to a route.
  583. # GET, POST, URI captures, then explicit data overrides.
  584. my $query = $self->extract_query( $path, $route, $env );
  585. # We failed validation if $query isn't a hashref.
  586. return $query if ref $query eq 'ARRAY';
  587. # allow this stuff to survive down to the end of some routes
  588. $query->{last_fetched} = $last_fetch;
  589. $query->{deflate} = $deflate;
  590. $query->{streaming} = $streaming;
  591. my @ranges = parse_ranges($env);
  592. $query->{ranges} = \@ranges;
  593. $query->{start} = $start;
  594. # Put things to tv_interval in here for Server-Timing
  595. $query->{fullpath} = $fullpath;
  596. $query->{method} = $route->{method};
  597. $query->{route} = $path;
  598. $query->{cookies} = $env->{HTTP_COOKIE};
  599. $query->{dnt} = $env->{HTTP_DNT};
  600. $query->{nosellinfo} = $env->{HTTP_SEC_GPC};
  601. $query->{port} = $port;
  602. $query->{scheme} = $env->{'psgi.url_scheme'} // 'http';
  603. $query->{method} = $env->{REQUEST_METHOD};
  604. $query->{lang} = $env->{HTTP_ACCEPT_LANGUAGE};
  605. $query->{accept} = $env->{HTTP_ACCEPT};
  606. $query->{has_query} = !!$env->{QUERY_STRING};
  607. $query->{domain} = $domain;
  608. $query->{dispatcher} = $route;
  609. $query->{ip} = $cur_query->{ip};
  610. $query->{ua} = $cur_query->{ua};
  611. $query->{referer} = $cur_query->{referer};
  612. # This allows for better error handlers if we die in the route.
  613. $cur_query = $query;
  614. # Setup the CGI vars they expect IF requested
  615. local %ENV = ( %ENV, CGI::Emulate::PSGI->emulate_environment($env) ) if $route->{env};
  616. {
  617. my $output = $callback->( $self, $query );
  618. # If it's streaming, just hand it off.
  619. # It's up to the caller to handle things like running post-close callbacks in this event.
  620. return $output if ref $output eq 'CODE';
  621. die "$fullpath returned no or malformed data!" unless ref $output eq 'ARRAY' && @$output == 3;
  622. my $pport = defined $query->{port} ? ":$query->{port}" : "";
  623. my %headers = @{ $output->[1] };
  624. my $bytes = $headers{'Content-Length'} // '?';
  625. $self->INFO("$env->{REQUEST_METHOD} $output->[0] $bytes $fullpath");
  626. # Append server-timing headers if they aren't present
  627. my $tot = tv_interval($start) * 1000;
  628. push( @{ $output->[1] }, 'Server-Timing' => "app;dur=$tot" ) unless List::Util::any { $_ eq 'Server-Timing' } @{ $output->[1] };
  629. # In the event that we have post-close callbacks, go ahead and run them.
  630. return $self->stream_raw_psgi( $output, $query ) if @{ $self->{callbacks} };
  631. return $output;
  632. }
  633. }
  634. sub extract_query {
  635. my ( $self, $path, $route, $env ) = @_;
  636. my $query = URL::Encode::url_params_mixed( $env->{QUERY_STRING} ) if $env->{QUERY_STRING};
  637. #Actually parse the POSTDATA and dump it into the QUERY object if this is a POST
  638. if ( $env->{REQUEST_METHOD} eq 'POST' ) {
  639. my $body = HTTP::Body->new( $env->{CONTENT_TYPE}, $env->{CONTENT_LENGTH} );
  640. while ( $env->{'psgi.input'}->read( my $buf, $CHUNK_SIZE ) ) {
  641. $body->add($buf);
  642. }
  643. @$query{ keys( %{ $body->param } ) } = values( %{ $body->param } );
  644. @$query{ keys( %{ $body->upload } ) } = values( %{ $body->upload } );
  645. }
  646. if ( ref $route->{captures} eq 'ARRAY' ) {
  647. my @captures = $path =~ m/^$route->{pattern}$/;
  648. @$query{ @{ $route->{captures} } } = @captures;
  649. }
  650. if ( ref $route->{data} eq 'HASH' ) {
  651. @$query{ keys( %{ $route->{data} } ) } = values( %{ $route->{data} } );
  652. }
  653. # Now that we've parsed the query and know where we want to go,
  654. # we should (optionally) murder everything the route does not explicitly want, and validate what it does
  655. my $parameters = $route->{parameters};
  656. if ($parameters) {
  657. die "invalid route definition for $path: bad parameters" unless ref $parameters eq 'HASH';
  658. my @known_params = keys(%$parameters);
  659. for my $param (@known_params) {
  660. die "Invalid route definition for $path: parameter $param must correspond to a validation CODEREF." unless ref $parameters->{$param} eq 'CODE';
  661. # A missing parameter is not necessarily a problem.
  662. next unless $query->{$param};
  663. # But if we have it, and it's bad, nack it, so that scanners get fail2banned.
  664. $self->DEBUG("Rejected $path for bad query param $param");
  665. return $self->badrequest($query) unless $parameters->{$param}->( $query->{$param} );
  666. }
  667. # Smack down passing of unnecessary fields
  668. foreach my $field ( keys(%$query) ) {
  669. next if List::Util::any { $field eq $_ } @known_params;
  670. next if List::Util::any { $field eq $_ } qw{start route streaming method fullpath};
  671. $self->DEBUG("Rejected $path for query param $field");
  672. return $self->badrequest($query);
  673. }
  674. }
  675. return $query;
  676. }
  677. # Read until we are done with headers, then pass off the filehandle to PSGI for streaming.
  678. # TODO support compression
  679. sub cgi {
  680. my ( $self, $env, $fullpath, $file_actual, $last_fetch, $deflate ) = @_;
  681. $self->INFO("Handoff $fullpath to $file_actual for cgi exec");
  682. # Setup the CGI vars they expect
  683. local %ENV = ( %ENV, CGI::Emulate::PSGI->emulate_environment($env) );
  684. my $pid = open( my $out, '-|', "$file_actual" );
  685. my ( $code, $offset, %headers ) = extract_headers( $out, $last_fetch );
  686. $code //= 500;
  687. return sub {
  688. my $responder = shift;
  689. my $writer = $responder->( [ $code, [%headers] ] );
  690. while ( $out->read( my $buf, $CHUNK_SIZE ) ) {
  691. $writer->write($buf);
  692. }
  693. $writer->close;
  694. # Wait on the CGI script to do whatever it's doing after closing stdout
  695. close $out;
  696. waitpid( $pid, 0 );
  697. };
  698. }
  699. # Used primarily when we have post-close callbacks
  700. sub stream_raw_psgi {
  701. my ( $self, $response, $data ) = @_;
  702. my $time_to_here = tv_interval( $data->{start} );
  703. $self->DEBUG("Routing took $time_to_here s");
  704. my $post_routing = [gettimeofday];
  705. my $fh = $self->{filehandle};
  706. print $fh "HTTP/1.1 $response->[0]\n";
  707. # Emit the rest of the headers
  708. foreach ( my $i = 0; $i < @{ $response->[1] }; $i += 2 ) {
  709. my $header = $response->[1][$i];
  710. my $value = $response->[1][ $i + 1 ];
  711. print $fh "$header: $value\n";
  712. }
  713. print $fh "\n";
  714. # Emit the body
  715. print $fh $response->[2][0];
  716. close($fh);
  717. $self->DEBUG( "Response written in " . ( tv_interval($post_routing) ) . " s" );
  718. while ( my $callback = shift @{ $self->{callbacks} } ) {
  719. if ( ref $callback eq 'CODE' ) {
  720. my $post_start = [gettimeofday];
  721. local $@;
  722. eval { $callback->() };
  723. $self->ERROR("Post-close callback encountered exception: $@") if $@;
  724. $self->DEBUG( "Post-close callback took " . ( tv_interval($post_start) ) . " s" );
  725. }
  726. }
  727. exit 0;
  728. }
  729. sub stream_raw_http {
  730. my ( $self, $data, $last_fetch, $to_fork, $callback, $error_handler ) = @_;
  731. my $time_to_here = tv_interval( $data->{start} );
  732. $self->DEBUG("Routing took $time_to_here s");
  733. my $post_routing = [gettimeofday];
  734. my $fh = $self->{filehandle};
  735. # The CGIs you are executing here SHOULD NOT emit a status line.
  736. # Most apache CGIs you find don't anyways, so this is usually not a big deal.
  737. print $fh "HTTP/1.1 200 OK\n";
  738. # Emit our server-timing header.
  739. print $fh "Server-Timing: " . $self->build_server_timing($data) . "\n"; #app;dur=".($time_to_here*1000)."\n";
  740. # We *must* fork because we can't rely on the child to close stdout.
  741. my $pid = _fork(
  742. sub {
  743. $self->DEBUG( "Forking child took" . ( tv_interval($post_routing) ) . " s" );
  744. local $@;
  745. eval { $to_fork->() };
  746. $self->ERROR("Raw HTTTP streaming encountered exception: $@") if $@;
  747. if ( ref $error_handler eq 'CODE' ) {
  748. local $@;
  749. eval { $error_handler->() };
  750. $self->ERROR("HTTP streaming error handler encountered exception: $@") if $@;
  751. }
  752. },
  753. $fh
  754. );
  755. waitpid( $pid, 0 );
  756. close($fh);
  757. $self->DEBUG( "Response written in " . ( tv_interval($post_routing) ) . " s" );
  758. if ( ref $callback eq 'CODE' ) {
  759. my $post_start = [gettimeofday];
  760. local $@;
  761. eval { $callback->() };
  762. $self->ERROR("Post-close callback encountered exception: $@") if $@;
  763. $self->DEBUG( "Post-close callback took " . ( tv_interval($post_start) ) . " s" );
  764. }
  765. exit 0;
  766. }
  767. sub _fork {
  768. my ( $callback, $fh ) = @_;
  769. select $fh;
  770. $| = 1;
  771. my $pid = fork();
  772. die "Could not fork child" unless defined $pid;
  773. if ( $pid == 0 ) {
  774. $callback->();
  775. exit 0;
  776. }
  777. select STDOUT;
  778. return $pid;
  779. }
  780. =head2 extract_headers($fh, $last_fetch)
  781. Extract the headers from a filehandle. Set the 'Last Modified' header appropriately based on the output of `stat` on the filehandle.
  782. 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.
  783. =cut
  784. sub extract_headers {
  785. my ( $fh, $last_fetch, $is_ref ) = @_;
  786. my $headers = '';
  787. # NOTE: this is relying on while advancing the file pointer
  788. while (<$fh>) {
  789. last if $_ eq "\n";
  790. $headers .= $_;
  791. }
  792. #XXX still a toctou
  793. my $offset = $fh->tell();
  794. my ( undef, undef, $status, undef, $headers_parsed ) = HTTP::Parser::XS::parse_http_response( "$headers\n", HEADERS_AS_HASHREF );
  795. my $code = $status // 200;
  796. $headers_parsed //= {};
  797. if ( !$is_ref ) {
  798. my $mt = ( stat($fh) )[9];
  799. my @gm = gmtime($mt);
  800. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  801. $code = $mt > $last_fetch ? $status : 304;
  802. $headers_parsed->{"Last-Modified"} = $now_string;
  803. }
  804. return ( $code, $offset, %$headers_parsed );
  805. }
  806. sub static {
  807. my ( $self, $fullpath, $path, $start, $streaming, $last_fetch ) = @_;
  808. $self->DEBUG("Rendering static for $path");
  809. # XXX because of psgi I can't just vomit the file directly
  810. if ( open( my $fh, '<', "statics/$path" ) ) {
  811. my ( $code, $offset, $headers_parsed ) = ( 200, undef, {} );
  812. ( $code, $offset, %$headers_parsed ) = extract_headers( $fh, $last_fetch );
  813. # Append server-timing headers
  814. my $tot = tv_interval($start) * 1000;
  815. $headers_parsed->{'Server-Timing'} = "static;dur=$tot";
  816. #XXX uwsgi just opens the file *again* when we already have a filehandle if it has a path.
  817. # starman by comparison doesn't violate the principle of least astonishment here.
  818. # This is probably a performance optimization, but makes the kind of micromanagement I need to do inconvenient.
  819. # As such, we will just return a stream.
  820. $self->INFO("GET 200 $headers_parsed->{'Content-Length'} $fullpath");
  821. return sub {
  822. my $responder = shift;
  823. #push(@headers, 'Content-Length' => $sz);
  824. my $writer = $responder->( [ $code, [%$headers_parsed] ] );
  825. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  826. $writer->write($buf);
  827. }
  828. close $fh;
  829. $writer->close;
  830. }
  831. if $streaming;
  832. return [ $code, [%$headers_parsed], $fh ];
  833. }
  834. return $self->forbidden( $self->{current_query} );
  835. }
  836. sub get_config {
  837. $ENV{HOME} ||= Cwd::getcwd();
  838. my %options = (
  839. verbose => 0,
  840. custom_log => undef,
  841. routers => [],
  842. loggers => [],
  843. auth => undef,
  844. domain => '',
  845. user => '',
  846. binds => [],
  847. basedir => $ENV{'HOME'},
  848. );
  849. my $config_file = "$ENV{HOME}/.tpsgi.ini";
  850. if ( -f $config_file ) {
  851. my $conf = Config::Simple->new($config_file);
  852. my %config;
  853. %config = %{ $conf->param( -block => 'default' ) } if $conf;
  854. # Merge the configuration with the options
  855. foreach my $opt ( keys(%options) ) {
  856. if ( ref $options{$opt} eq 'ARRAY' ) {
  857. next unless exists $config{$opt};
  858. my @arrayed = ref $config{$opt} eq 'ARRAY' ? @{ $config{$opt} } : ( $config{$opt} );
  859. push( @{ $options{$opt} }, @arrayed );
  860. next;
  861. }
  862. $options{$opt} = $config{$opt} if exists $config{$opt};
  863. }
  864. }
  865. return %options;
  866. }
  867. # Convenience method to keep track of server-timing
  868. sub checkpoint {
  869. my ( $self, $data, $name ) = @_;
  870. $data->{checkpoints} //= [ [ start => $data->{start} ] ];
  871. push( @{ $data->{checkpoints} }, [ $name => [gettimeofday] ] );
  872. }
  873. sub build_server_timing {
  874. my ( $self, $data ) = @_;
  875. my @st;
  876. my $last_interval;
  877. foreach my $checkpoint ( @{ $data->{checkpoints} } ) {
  878. if ($last_interval) {
  879. my $dur = tv_interval( $last_interval, $checkpoint->[1] ) * 1000;
  880. push( @st, "$checkpoint->[0];dur=$dur" );
  881. }
  882. $last_interval = $checkpoint->[1];
  883. }
  884. my $tot = tv_interval( $data->{start} ) * 1000;
  885. push( @st, "tot;dur=$tot" );
  886. delete $data->{checkpoints};
  887. return join( ', ', @st );
  888. }
  889. =head2 add_post_close_callback()
  890. Add a thing to do after closing stdout; useful for housekeeping that doesn't need to hold up pageload.
  891. =cut
  892. sub add_post_close_callback {
  893. my ( $self, $cb ) = @_;
  894. push( @{ $self->{callbacks} }, $cb );
  895. }
  896. =head2 signal_restart_parent()
  897. Instruct tPSGI to reload after closing stdout.
  898. =cut
  899. sub signal_restart_parent {
  900. my ($self) = @_;
  901. $self->add_post_close_callback( \&_restart_parent );
  902. }
  903. # Instruct the parent to restart. Normally this is HUP, but nginx-unit decides to be special.
  904. # Don't do anything if running NOHUP=1, which is useful when doing bulk operations
  905. sub _restart_parent {
  906. my $parent = shift;
  907. return if $ENV{NOHUP};
  908. if ( $ENV{PSGI_ENGINE} && $ENV{PSGI_ENGINE} eq 'nginx-unit' ) {
  909. my $conf = Trog::Config->get();
  910. my $nginx_socket = $conf->param('nginx-unit.socket');
  911. my $client = HTTP::Tiny::UNIX->new();
  912. my $res = $client->request( 'GET', "http:$nginx_socket//control/applications/tcms/restart" );
  913. WARN("could not reload application (got $res->{status} from nginx-unit)!") unless $res->{status} == 200;
  914. return 1;
  915. }
  916. $parent //= getppid;
  917. kill 'HUP', $parent;
  918. }
  919. =head2 save_render($path, $extension, $body)
  920. Save a static render of the page.
  921. Good to queue after closing stdout.
  922. Passed file extension slapped on the end of the path.
  923. You can then serve them as statics with an appropriately done try_files block in nginx.
  924. =cut
  925. # fixup matrix
  926. my %fixup = (
  927. text => 'txt',
  928. blob => 'bin',
  929. );
  930. sub save_render {
  931. my ($self, $path, $extension, $body) = @_;
  932. return unless $path && $extension;
  933. # Fixup the extension if needed.
  934. $extension = $fixup{$extension} if exists $fixup{$extension};
  935. my $path_fixed = $path;
  936. $path_fixed =~ s/\.\Q$extension\E$//;
  937. $path_fixed = "$path_fixed.$extension";
  938. Path::Tiny::path( "www/static/" . dirname( $path_fixed ) )->mkpath;
  939. my $file = "www/static/$path_fixed";
  940. my $verb = -f $file ? 'Overwrite' : 'Write';
  941. $self->INFO("$verb $path as static/$path_fixed");
  942. open( my $fh, '>', $file ) or die "Could not open $file for writing";
  943. print $fh $body;
  944. close $fh;
  945. }
  946. sub invalidate_render {
  947. my ($self, $path, $extension) = @_;
  948. return unless $path && $extension;
  949. # Fixup the extension if needed.
  950. $extension = $fixup{$extension} if exists $fixup{$extension};
  951. my $path_fixed = $path;
  952. $path_fixed =~ s/\.\Q$extension\E$//;
  953. $path_fixed = "$path_fixed.$extension";
  954. Path::Tiny::path( "www/static/" . dirname( $path_fixed ) )->mkpath;
  955. my $file = "www/static/$path_fixed";
  956. return unless -f $file;
  957. $self->INFO("Delete $path as $file");
  958. unlink "$file";
  959. }
  960. 1;