TPSGI.pm 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
  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. }
  199. else {
  200. die "Could not load $route!\n$@\n";
  201. }
  202. }
  203. use strict 'refs';
  204. # Set the pattern used to discover the route if needed later.
  205. for ( my $i = 0; $i < scalar(@routes); $i += 2 ) {
  206. $routes[ $i + 1 ]{pattern} = $routes[$i];
  207. }
  208. $self->{indices} //= [];
  209. $self->{indices} = [ @{ $self->{indices} }, qw{index.html index.htm index.cgi} ];
  210. $self->{routes} = \@routes;
  211. $self->{aliases} = \%aliases;
  212. $self->{callbacks} = [];
  213. return $self;
  214. }
  215. sub indices {
  216. my $self = shift;
  217. return $self->{indices};
  218. }
  219. sub routes {
  220. my $self = shift;
  221. return $self->{routes};
  222. }
  223. =head2 serve
  224. Serve a file, with options to stream and cache the output.
  225. =cut
  226. sub serve {
  227. my ( $self, $fullpath, $path, $start, $streaming, $ranges, $last_fetch, $deflate ) = @_;
  228. $last_fetch ||= 0;
  229. $deflate ||= 0;
  230. my $mf = Mojo::File->new($path);
  231. my $ext = '.' . $mf->extname();
  232. my $ft;
  233. if ($ext) {
  234. $ft = Plack::MIME->mime_type($ext) if $ext;
  235. $ft ||= $extra_types{$ext} if exists $extra_types{$ext};
  236. }
  237. $ft ||= $content_types{text};
  238. my @headers = ( $ct => $ft );
  239. #TODO use static Cache-Control for everything but JS/CSS?
  240. push( @headers, 'Cache-control' => $cache_control{revalidate} );
  241. push( @headers, 'Accept-Ranges' => 'bytes' );
  242. $self->DEBUG("FETCH $path");
  243. my $mt = ( stat($path) )[9];
  244. my $sz = ( stat(_) )[7];
  245. my @gm = gmtime($mt);
  246. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  247. my $code = $mt > $last_fetch ? 200 : 304;
  248. push( @headers, "Last-Modified" => $now_string );
  249. push( @headers, 'Vary' => 'Accept-Encoding' );
  250. if ( open( my $fh, '<', $path ) ) {
  251. return $self->_range( $fullpath, $fh, $ranges, $sz, @headers ) if @$ranges && $streaming;
  252. # Transfer-encoding: chunked
  253. return sub {
  254. my $responder = shift;
  255. push( @headers, 'Content-Length' => $sz );
  256. my $writer = $responder->( [ $code, \@headers ] );
  257. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  258. $writer->write($buf);
  259. }
  260. close $fh;
  261. $writer->close;
  262. }
  263. if $streaming && $sz > $CHUNK_SIZE;
  264. #Return data in the event the caller does not support deflate
  265. if ( !$deflate ) {
  266. push( @headers, "Content-Length" => $sz );
  267. # Append server-timing headers
  268. my $tot = tv_interval($start) * 1000;
  269. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  270. return [ $code, \@headers, $fh ];
  271. }
  272. #Compress everything less than 1MB
  273. push( @headers, "Content-Encoding" => "gzip" );
  274. my $dfh;
  275. IO::Compress::Gzip::gzip( $fh => \$dfh );
  276. print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
  277. push( @headers, "Content-Length" => length($dfh) );
  278. $self->INFO("GET 200 $fullpath");
  279. # Append server-timing headers
  280. my $tot = tv_interval($start) * 1000;
  281. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  282. return [ $code, \@headers, [$dfh] ];
  283. }
  284. $self->INFO("GET 403 $fullpath");
  285. return [ 403, [ $ct => $content_types{text} ], ["Forbidden"] ];
  286. }
  287. sub _range {
  288. my ( $self, $fullpath, $fh, $ranges, $sz, %headers ) = @_;
  289. # Set mode
  290. my $primary_ct = "Content-Type: $headers{'Content-type'}";
  291. my $is_multipart = scalar(@$ranges) > 1;
  292. if ($is_multipart) {
  293. $headers{'Content-type'} = "multipart/byteranges; boundary=$CHUNK_SEP";
  294. }
  295. my $code = 206;
  296. my $fc = '';
  297. # Calculate the content-length up-front. We have to fix unspecified lengths first, and reject bad requests.
  298. foreach my $range (@$ranges) {
  299. $range->[1] //= $sz - 1;
  300. $self->INFO("GET 416 $fullpath");
  301. return [ 416, [%headers], ["Requested range not satisfiable"] ] if $range->[0] > $sz || $range->[0] < 0 || $range->[1] < 0 || $range->[0] > $range->[1];
  302. }
  303. $headers{'Content-Length'} = List::Util::sum( map { my $arr = $_; $arr->[1] + 1, -$arr->[0] } @$ranges );
  304. #XXX Add the entity header lengths to the value - should hash-ify this to DRY
  305. if ($is_multipart) {
  306. foreach my $range (@$ranges) {
  307. $headers{'Content-Length'} += length("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n");
  308. $fc = "\n";
  309. }
  310. $headers{'Content-Length'} += length("\n--$CHUNK_SEP\--\n");
  311. $fc = '';
  312. }
  313. return sub {
  314. my $responder = shift;
  315. my $writer;
  316. foreach my $range (@$ranges) {
  317. $headers{'Content-Range'} = "bytes $range->[0]-$range->[1]/$sz" unless $is_multipart;
  318. $writer //= $responder->( [ $code, [%headers] ] );
  319. $writer->write("$fc--$CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n") if $is_multipart;
  320. $fc = "\n";
  321. my $len = List::Util::min( $sz, $range->[1] + 1 ) - $range->[0];
  322. $fh->seek( $range->[0], 0 );
  323. while ($len) {
  324. $fh->read( my $buf, List::Util::min( $len, $CHUNK_SIZE ) );
  325. $writer->write($buf);
  326. # Adjust for amount written
  327. $len = List::Util::max( $len - $CHUNK_SIZE, 0 );
  328. }
  329. }
  330. $fh->close();
  331. $writer->write("\n--$CHUNK_SEP\--\n") if $is_multipart;
  332. $writer->close;
  333. };
  334. }
  335. sub _generic {
  336. my ( $type, $code ) = @_;
  337. $type .= Carp::longmess() if $debug;
  338. return [ $code, [ $ct => $content_types{html} ], ["$type"] ];
  339. }
  340. =head2 redirect, redirect_permanent, see_also
  341. Redirects to the provided page.
  342. =cut
  343. sub redirect {
  344. my ( $self, $to ) = @_;
  345. $self->INFO("redirect: $to");
  346. return [ 302, [ "Location" => $to, "Content-Length" => 0 ], [''] ];
  347. }
  348. sub redirect_permanent {
  349. my ( $self, $to ) = @_;
  350. $self->INFO("permanent redirect: $to");
  351. return [ 301, [ "Location" => $to, "Content-Length" => 0 ], [''] ];
  352. }
  353. sub see_also {
  354. my ( $self, $to ) = @_;
  355. $self->INFO("see also: $to");
  356. return [ 303, [ "Location" => $to, "Content-Length" => 0 ], [''] ];
  357. }
  358. =head2 notfound, forbidden, badrequest, toolong, error
  359. If you need to return these HTTP errors, return these within a route:
  360. sub route {
  361. my ($self, $query) = @_;
  362. ...
  363. return $self->notfound($query);
  364. }
  365. =cut
  366. sub notfound {
  367. my ( $self, $query, $body ) = @_;
  368. $self->INFO("$query->{method} 404 $query->{fullpath}");
  369. $body //= 'Not Found';
  370. return _generic( $body, 404 );
  371. }
  372. sub forbidden {
  373. my ( $self, $query, $body ) = @_;
  374. $self->INFO("$query->{method} 403 $query->{fullpath}");
  375. $body //= 'Forbidden';
  376. return _generic( $body, 403 );
  377. }
  378. sub badrequest {
  379. my ( $self, $query, $body ) = @_;
  380. $self->INFO("$query->{method} 400 $query->{fullpath}");
  381. $body //= 'Bad Request';
  382. return _generic( $body, 400 );
  383. }
  384. sub toolong {
  385. my ( $self, $query, $body ) = @_;
  386. $self->INFO("$query->{method} 419 $query->{fullpath}");
  387. $body //= 'URI too long';
  388. return _generic( $body, 419 );
  389. }
  390. sub error {
  391. my ( $self, $query, $body ) = @_;
  392. my $method = $query->{method} // "?";
  393. my $fp = $query->{fullpath} // "?";
  394. $body //= 'Internal Server Error';
  395. $self->INFO("$method 500 $fp");
  396. return _generic( $body, 500 );
  397. }
  398. sub unavailable {
  399. my ( $self, $query, $body ) = @_;
  400. $self->INFO("$query->{method} 503 $query->{fullpath}");
  401. $body //= 'Service Unavailable';
  402. return _generic( $body, 503 );
  403. }
  404. my $cur_query = {};
  405. sub app {
  406. my $self = shift;
  407. return eval { _app( $self, @_ ) } || do {
  408. my $env = shift;
  409. $env->{'psgi.errors'}->print($@) if $env->{'psgi.errors'};
  410. # Redact the stack trace past line 1, it usually has things which should not be shown
  411. $cur_query->{message} = $@;
  412. $cur_query->{message} =~ s/\n.*//g if $cur_query->{message} && !$debug;
  413. return $self->error($cur_query);
  414. };
  415. }
  416. my %etags;
  417. sub _app {
  418. my $self = shift;
  419. # Start the server timing clock
  420. my $start = [gettimeofday];
  421. $cur_query = {};
  422. # Don't make things group read/write
  423. umask 0007;
  424. my $env = shift;
  425. $self->{filehandle} = $env->{'psgix.io'} // *STDOUT;
  426. # Setup the unique ID for the request
  427. $env->{REQUEST_ID} = $self->request_id(1);
  428. # Discard the path used in the log, it's too long and enough 4xx error code = ban
  429. return $self->toolong( { method => $env->{REQUEST_METHOD}, fullpath => '...' } ) if length( $env->{REQUEST_URI} ) > 2048;
  430. # Various stuff important for logging requests
  431. my $domain = $env->{HTTP_X_FORWARDED_HOST} || $env->{HTTP_HOST} // eval { Sys::Hostname::hostname() };
  432. my $path = $env->{PATH_INFO};
  433. my $port = $env->{HTTP_X_FORWARDED_PORT} // $env->{HTTP_PORT};
  434. my $pport = defined $port ? ":$port" : "";
  435. my $scheme = $env->{'psgi.url_scheme'} // 'http';
  436. my $method = $env->{REQUEST_METHOD};
  437. # It's important that we log what the user ACTUALLY requested rather than the rewritten path later on.
  438. my $fullpath = "$scheme://$domain$pport$path";
  439. # So we can log it for fail2ban
  440. my $ip = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR};
  441. # set the referer & ua to go into DB logs, but not logs in general.
  442. # The referer/ua largely has no importance beyond being a proto bug report for log messages.
  443. my $referer = $env->{HTTP_REFERER};
  444. my $ua = $env->{HTTP_UA};
  445. $cur_query = {
  446. route => $path,
  447. fullpath => $path,
  448. method => $method,
  449. ip => $ip,
  450. ua => $ua,
  451. referer => $referer,
  452. };
  453. # Support aliased paths
  454. my $aliases = $self->{aliases};
  455. $path = $aliases->{$path} if exists $aliases->{$path};
  456. # Check eTags. If we don't know about it, just assume it's good and lazily fill the cache
  457. # XXX yes, this allows cache poisoning...but only for logged in users!
  458. # This also needs to be IN DB so that we coordinate properly across forks
  459. if ( $env->{HTTP_IF_NONE_MATCH} ) {
  460. $self->INFO("$env->{REQUEST_METHOD} 304 $fullpath");
  461. return [ 304, [], [''] ] if $env->{HTTP_IF_NONE_MATCH} eq ( $etags{ $env->{REQUEST_URI} } || '' );
  462. $etags{ $env->{REQUEST_URI} } = $env->{HTTP_IF_NONE_MATCH} unless exists $etags{ $env->{REQUEST_URI} };
  463. }
  464. # TODO: Actually do something with the language passed to the renderer
  465. my $lang = $env->{HTTP_ACCEPT_LANGUAGE};
  466. #TODO: Actually do something with the acceptable output formats in the renderer
  467. my $accept = $env->{HTTP_ACCEPT};
  468. my $last_fetch = 0;
  469. if ( $env->{HTTP_IF_MODIFIED_SINCE} ) {
  470. $last_fetch = DateTime::Format::HTTP->parse_datetime( $env->{HTTP_IF_MODIFIED_SINCE} )->epoch();
  471. }
  472. # Figure out if we want compression or not
  473. my $alist = $env->{HTTP_ACCEPT_ENCODING} || '';
  474. $alist =~ s/\s//g;
  475. my @accept_encodings;
  476. @accept_encodings = split( /,/, $alist );
  477. my $deflate = grep { 'gzip' eq $_ } @accept_encodings;
  478. # Set the IP of the request so we can fail2ban
  479. $self->{ip} = $env->{HTTP_X_FORWARDED_FOR} || $env->{REMOTE_ADDR} || $self->{ip};
  480. # Make sure this works further down the line
  481. $env->{REMOTE_ADDR} = $env->{HTTP_X_FORWARDED_FOR} if $env->{HTTP_X_FORWARDED_FOR};
  482. my $streaming = $env->{'psgi.streaming'};
  483. # If we have an actual route, just use it.
  484. my $r = $self->routes;
  485. my $route_index = List::Util::first { ( $r->[$_] // '' ) eq $path } 0 .. scalar(@$r);
  486. my $route_actual;
  487. $route_actual = $r->[ $route_index + 1 ] if defined($route_index);
  488. # Might be a regexed route. Sort reversed so we try the longest routes first.
  489. if ( !$route_actual && @$r ) {
  490. my $matched = List::Util::first {
  491. # Here's where you want to use Regexp::Debugger in the context of call.pl to debug routes... EX:
  492. # perl ./call.pl GET /path/to/route
  493. #print $r->[$_]."\n";
  494. $path =~ m/^$r->[$_]$/;
  495. }
  496. 0 .. scalar(@$r) - 1;
  497. $route_actual = $r->[ $matched + 1 ] if defined($matched);
  498. }
  499. return $self->route( $route_actual, $env, $fullpath, $path, $start, $last_fetch, $deflate, $domain, $port ) if $route_actual;
  500. # Do a case insensitive match, because osx and windows
  501. my $file_possible = $self->mangle_filename("www/$path");
  502. my $file_actual = $file_possible if -f $file_possible;
  503. # Dirindices
  504. if ( -d $file_possible ) {
  505. foreach my $index ( @{ $self->indices } ) {
  506. next if $file_actual;
  507. my $dirindex = $self->mangle_filename("www$path/$index");
  508. $dirindex =~ s|//|/|g;
  509. $file_actual = $dirindex if -f $dirindex;
  510. }
  511. }
  512. my $file_is_cgi = appears_executable($file_actual);
  513. return $self->cgi( $env, $fullpath, $file_actual, $last_fetch, $deflate ) if $file_is_cgi;
  514. $self->INFO( "Attempting to serve $fullpath [" . ( $file_possible // "" ) . "]" );
  515. my @ranges = parse_ranges($env);
  516. return $self->serve( $fullpath, $file_actual, $start, $streaming, \@ranges, $last_fetch, $deflate ) if $file_actual;
  517. return $self->notfound($cur_query);
  518. }
  519. sub mangle_filename {
  520. my ( $self, $path ) = @_;
  521. # XXX yes, you can have case sensitive OSX, but basically nobody enables that ever
  522. $path = lc($path) if List::Util::any { $^O eq $_ } qw{darwin MSWin32 dos};
  523. $self->DEBUG("Path mangled to $path");
  524. return $path;
  525. }
  526. sub parse_ranges {
  527. my $env = shift;
  528. # Handle HTTP range/streaming requests
  529. my $range = $env->{HTTP_RANGE} || "bytes=0-" if $env->{HTTP_RANGE} || $env->{HTTP_IF_RANGE};
  530. my @ranges;
  531. if ($range) {
  532. $range =~ s/bytes=//g;
  533. push(
  534. @ranges,
  535. map { [ split( /-/, $_ ) ]; } split( /,/, $range )
  536. );
  537. }
  538. return @ranges;
  539. }
  540. my @executable_extensions = qw{cgi sh exe pl php py};
  541. sub appears_executable {
  542. my $subj = shift;
  543. # Definitely not executable if it does not exist!
  544. return 0 unless $subj;
  545. return 0 unless -x $subj;
  546. my ($extension) = $subj =~ m/\.(\S+)$/;
  547. return 1 if !$extension;
  548. return 1 if grep { $extension eq $_ } @executable_extensions;
  549. return 0;
  550. }
  551. # Handle actual routes
  552. sub route {
  553. my ( $self, $route, $env, $fullpath, $path, $start, $last_fetch, $deflate, $domain, $port ) = @_;
  554. $self->DEBUG( 'Executing route ' . $route->{pattern} );
  555. my $content_type = $env->{CONTENT_TYPE} || 'text/html'; # If not set, that's the assumption.
  556. # It is the responsibility of each route to respond to HEAD requests correctly
  557. return $self->badrequest($cur_query) unless List::Util::any { $_ eq $env->{REQUEST_METHOD} } grep { defined $_ } ( 'HEAD', $route->{method} );
  558. my $callback;
  559. if ( exists $route->{callbacks}{'*'} ) {
  560. $callback = $route->{callbacks}{'*'};
  561. }
  562. else {
  563. return $self->badrequest($cur_query) unless exists $route->{callbacks}{$content_type};
  564. $callback = $route->{callbacks}{$content_type};
  565. }
  566. # Provide static renders if available.
  567. # TODO allow routes to signal back up to us if they want to go static.
  568. my $streaming = $env->{'psgi.streaming'};
  569. return $self->static( $fullpath, "$fullpath.z", $start, $streaming ) if -f "statics/$fullpath.z" && $deflate;
  570. return $self->static( $fullpath, $fullpath, $start, $streaming ) if -f "statics/$fullpath";
  571. # Build the query data for passing to a route.
  572. # GET, POST, URI captures, then explicit data overrides.
  573. my $query = $self->extract_query( $path, $route, $env );
  574. # allow this stuff to survive down to the end of some routes
  575. $query->{last_fetched} = $last_fetch;
  576. $query->{deflate} = $deflate;
  577. $query->{streaming} = $streaming;
  578. my @ranges = parse_ranges($env);
  579. $query->{ranges} = \@ranges;
  580. $query->{start} = $start;
  581. # Put things to tv_interval in here for Server-Timing
  582. $query->{fullpath} = $fullpath;
  583. $query->{method} = $route->{method};
  584. $query->{route} = $path;
  585. $query->{cookies} = $env->{HTTP_COOKIE};
  586. $query->{dnt} = $env->{HTTP_DNT};
  587. $query->{nosellinfo} = $env->{HTTP_SEC_GPC};
  588. $query->{port} = $port;
  589. $query->{scheme} = $env->{'psgi.url_scheme'} // 'http';
  590. $query->{method} = $env->{REQUEST_METHOD};
  591. $query->{lang} = $env->{HTTP_ACCEPT_LANGUAGE};
  592. $query->{accept} = $env->{HTTP_ACCEPT};
  593. $query->{has_query} = !!$env->{QUERY_STRING};
  594. $query->{domain} = $domain;
  595. $query->{dispatcher} = $route;
  596. $query->{ip} = $cur_query->{ip};
  597. $query->{ua} = $cur_query->{ua};
  598. $query->{referer} = $cur_query->{referer};
  599. # This allows for better error handlers if we die in the route.
  600. $cur_query = $query;
  601. # Setup the CGI vars they expect IF requested
  602. local %ENV = ( %ENV, CGI::Emulate::PSGI->emulate_environment($env) ) if $route->{env};
  603. {
  604. my $output = $callback->( $self, $query );
  605. # If it's streaming, just hand it off.
  606. # It's up to the caller to handle things like running post-close callbacks in this event.
  607. return $output if ref $output eq 'CODE';
  608. die "$fullpath returned no or malformed data!" unless ref $output eq 'ARRAY' && @$output == 3;
  609. my $pport = defined $query->{port} ? ":$query->{port}" : "";
  610. my %headers = @{ $output->[1] };
  611. my $bytes = $headers{'Content-Length'} // '?';
  612. $self->INFO("$env->{REQUEST_METHOD} $output->[0] $bytes $fullpath");
  613. # Append server-timing headers if they aren't present
  614. my $tot = tv_interval($start) * 1000;
  615. push( @{ $output->[1] }, 'Server-Timing' => "app;dur=$tot" ) unless List::Util::any { $_ eq 'Server-Timing' } @{ $output->[1] };
  616. # In the event that we have post-close callbacks, go ahead and run them.
  617. return $self->stream_raw_psgi( $output, $query ) if @{ $self->{callbacks} };
  618. return $output;
  619. }
  620. }
  621. sub extract_query {
  622. my ( $self, $path, $route, $env ) = @_;
  623. my $query = URL::Encode::url_params_mixed( $env->{QUERY_STRING} ) if $env->{QUERY_STRING};
  624. #Actually parse the POSTDATA and dump it into the QUERY object if this is a POST
  625. if ( $env->{REQUEST_METHOD} eq 'POST' ) {
  626. my $body = HTTP::Body->new( $env->{CONTENT_TYPE}, $env->{CONTENT_LENGTH} );
  627. while ( $env->{'psgi.input'}->read( my $buf, $CHUNK_SIZE ) ) {
  628. $body->add($buf);
  629. }
  630. @$query{ keys( %{ $body->param } ) } = values( %{ $body->param } );
  631. @$query{ keys( %{ $body->upload } ) } = values( %{ $body->upload } );
  632. }
  633. if ( ref $route->{captures} eq 'ARRAY' ) {
  634. my @captures = $path =~ m/^$route->{pattern}$/;
  635. @$query{ @{ $route->{captures} } } = @captures;
  636. }
  637. if ( ref $route->{data} eq 'HASH' ) {
  638. @$query{ keys( %{ $route->{data} } ) } = values( %{ $route->{data} } );
  639. }
  640. return $query;
  641. }
  642. # Read until we are done with headers, then pass off the filehandle to PSGI for streaming.
  643. # TODO support compression
  644. sub cgi {
  645. my ( $self, $env, $fullpath, $file_actual, $last_fetch, $deflate ) = @_;
  646. $self->INFO("Handoff $fullpath to $file_actual for cgi exec");
  647. # Setup the CGI vars they expect
  648. local %ENV = ( %ENV, CGI::Emulate::PSGI->emulate_environment($env) );
  649. my $pid = open( my $out, '-|', "$file_actual" );
  650. my ( $code, $offset, %headers ) = extract_headers( $out, $last_fetch );
  651. $code //= 500;
  652. return sub {
  653. my $responder = shift;
  654. my $writer = $responder->( [ $code, [%headers] ] );
  655. while ( $out->read( my $buf, $CHUNK_SIZE ) ) {
  656. $writer->write($buf);
  657. }
  658. $writer->close;
  659. # Wait on the CGI script to do whatever it's doing after closing stdout
  660. close $out;
  661. waitpid( $pid, 0 );
  662. };
  663. }
  664. # Used primarily when we have post-close callbacks
  665. sub stream_raw_psgi {
  666. my ( $self, $response, $data ) = @_;
  667. my $time_to_here = tv_interval( $data->{start} );
  668. $self->DEBUG("Routing took $time_to_here s");
  669. my $post_routing = [gettimeofday];
  670. my $fh = $self->{filehandle};
  671. print $fh "HTTP/1.1 $response->[0]\n";
  672. # Emit the rest of the headers
  673. foreach ( my $i = 0; $i < @{ $response->[1] }; $i += 2 ) {
  674. my $header = $response->[1][$i];
  675. my $value = $response->[1][ $i + 1 ];
  676. print $fh "$header: $value\n";
  677. }
  678. print $fh "\n";
  679. # Emit the body
  680. print $fh $response->[2][0];
  681. close($fh);
  682. $self->DEBUG( "Response written in " . ( tv_interval($post_routing) ) . " s" );
  683. while ( my $callback = shift @{ $self->{callbacks} } ) {
  684. if ( ref $callback eq 'CODE' ) {
  685. my $post_start = [gettimeofday];
  686. local $@;
  687. eval { $callback->() };
  688. $self->ERROR("Post-close callback encountered exception: $@") if $@;
  689. $self->DEBUG( "Post-close callback took " . ( tv_interval($post_start) ) . " s" );
  690. }
  691. }
  692. exit 0;
  693. }
  694. sub stream_raw_http {
  695. my ( $self, $data, $last_fetch, $to_fork, $callback, $error_handler ) = @_;
  696. my $time_to_here = tv_interval( $data->{start} );
  697. $self->DEBUG("Routing took $time_to_here s");
  698. my $post_routing = [gettimeofday];
  699. my $fh = $self->{filehandle};
  700. # The CGIs you are executing here SHOULD NOT emit a status line.
  701. # Most apache CGIs you find don't anyways, so this is usually not a big deal.
  702. print $fh "HTTP/1.1 200 OK\n";
  703. # Emit our server-timing header.
  704. print $fh "Server-Timing: " . $self->build_server_timing($data) . "\n"; #app;dur=".($time_to_here*1000)."\n";
  705. # We *must* fork because we can't rely on the child to close stdout.
  706. my $pid = _fork(
  707. sub {
  708. $self->DEBUG( "Forking child took" . ( tv_interval($post_routing) ) . " s" );
  709. local $@;
  710. eval { $to_fork->() };
  711. $self->ERROR("Raw HTTTP streaming encountered exception: $@") if $@;
  712. if ( ref $error_handler eq 'CODE' ) {
  713. local $@;
  714. eval { $error_handler->() };
  715. $self->ERROR("HTTP streaming error handler encountered exception: $@") if $@;
  716. }
  717. },
  718. $fh
  719. );
  720. waitpid( $pid, 0 );
  721. close($fh);
  722. $self->DEBUG( "Response written in " . ( tv_interval($post_routing) ) . " s" );
  723. if ( ref $callback eq 'CODE' ) {
  724. my $post_start = [gettimeofday];
  725. local $@;
  726. eval { $callback->() };
  727. $self->ERROR("Post-close callback encountered exception: $@") if $@;
  728. $self->DEBUG( "Post-close callback took " . ( tv_interval($post_start) ) . " s" );
  729. }
  730. exit 0;
  731. }
  732. sub _fork {
  733. my ( $callback, $fh ) = @_;
  734. select $fh;
  735. $| = 1;
  736. my $pid = fork();
  737. die "Could not fork child" unless defined $pid;
  738. if ( $pid == 0 ) {
  739. $callback->();
  740. exit 0;
  741. }
  742. select STDOUT;
  743. return $pid;
  744. }
  745. =head2 extract_headers($fh, $last_fetch)
  746. Extract the headers from a filehandle. Set the 'Last Modified' header appropriately based on the output of `stat` on the filehandle.
  747. 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.
  748. =cut
  749. sub extract_headers {
  750. my ( $fh, $last_fetch, $is_ref ) = @_;
  751. my $headers = '';
  752. # NOTE: this is relying on while advancing the file pointer
  753. while (<$fh>) {
  754. last if $_ eq "\n";
  755. $headers .= $_;
  756. }
  757. #XXX still a toctou
  758. my $offset = $fh->tell();
  759. my ( undef, undef, $status, undef, $headers_parsed ) = HTTP::Parser::XS::parse_http_response( "$headers\n", HEADERS_AS_HASHREF );
  760. my $code = $status // 200;
  761. $headers_parsed //= {};
  762. if ( !$is_ref ) {
  763. my $mt = ( stat($fh) )[9];
  764. my @gm = gmtime($mt);
  765. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  766. $code = $mt > $last_fetch ? $status : 304;
  767. $headers_parsed->{"Last-Modified"} = $now_string;
  768. }
  769. return ( $code, $offset, %$headers_parsed );
  770. }
  771. sub static {
  772. my ( $self, $fullpath, $path, $start, $streaming, $last_fetch ) = @_;
  773. $self->DEBUG("Rendering static for $path");
  774. # XXX because of psgi I can't just vomit the file directly
  775. if ( open( my $fh, '<', "statics/$path" ) ) {
  776. my ( $code, $offset, $headers_parsed ) = ( 200, undef, {} );
  777. ( $code, $offset, %$headers_parsed ) = extract_headers( $fh, $last_fetch );
  778. # Append server-timing headers
  779. my $tot = tv_interval($start) * 1000;
  780. $headers_parsed->{'Server-Timing'} = "static;dur=$tot";
  781. #XXX uwsgi just opens the file *again* when we already have a filehandle if it has a path.
  782. # starman by comparison doesn't violate the principle of least astonishment here.
  783. # This is probably a performance optimization, but makes the kind of micromanagement I need to do inconvenient.
  784. # As such, we will just return a stream.
  785. $self->INFO("GET 200 $headers_parsed->{'Content-Length'} $fullpath");
  786. return sub {
  787. my $responder = shift;
  788. #push(@headers, 'Content-Length' => $sz);
  789. my $writer = $responder->( [ $code, [%$headers_parsed] ] );
  790. while ( $fh->read( my $buf, $CHUNK_SIZE ) ) {
  791. $writer->write($buf);
  792. }
  793. close $fh;
  794. $writer->close;
  795. }
  796. if $streaming;
  797. return [ $code, [%$headers_parsed], $fh ];
  798. }
  799. return $self->forbidden( $self->{current_query} );
  800. }
  801. sub get_config {
  802. $ENV{HOME} ||= Cwd::getcwd();
  803. my %options = (
  804. verbose => 0,
  805. custom_log => undef,
  806. routers => [],
  807. loggers => [],
  808. auth => undef,
  809. domain => '',
  810. user => '',
  811. binds => [],
  812. basedir => $ENV{'HOME'},
  813. );
  814. my $config_file = "$ENV{HOME}/.tpsgi.ini";
  815. if ( -f $config_file ) {
  816. my $conf = Config::Simple->new($config_file);
  817. my %config;
  818. %config = %{ $conf->param( -block => 'default' ) } if $conf;
  819. # Merge the configuration with the options
  820. foreach my $opt ( keys(%options) ) {
  821. if ( ref $options{$opt} eq 'ARRAY' ) {
  822. next unless exists $config{$opt};
  823. my @arrayed = ref $config{$opt} eq 'ARRAY' ? @{ $config{$opt} } : ( $config{$opt} );
  824. push( @{ $options{$opt} }, @arrayed );
  825. next;
  826. }
  827. $options{$opt} = $config{$opt} if exists $config{$opt};
  828. }
  829. }
  830. return %options;
  831. }
  832. # Convenience method to keep track of server-timing
  833. sub checkpoint {
  834. my ( $self, $data, $name ) = @_;
  835. $data->{checkpoints} //= [ [ start => $data->{start} ] ];
  836. push( @{ $data->{checkpoints} }, [ $name => [gettimeofday] ] );
  837. }
  838. sub build_server_timing {
  839. my ( $self, $data ) = @_;
  840. my @st;
  841. my $last_interval;
  842. foreach my $checkpoint ( @{ $data->{checkpoints} } ) {
  843. if ($last_interval) {
  844. my $dur = tv_interval( $last_interval, $checkpoint->[1] ) * 1000;
  845. push( @st, "$checkpoint->[0];dur=$dur" );
  846. }
  847. $last_interval = $checkpoint->[1];
  848. }
  849. my $tot = tv_interval( $data->{start} ) * 1000;
  850. push( @st, "tot;dur=$tot" );
  851. delete $data->{checkpoints};
  852. return join( ', ', @st );
  853. }
  854. =head2 add_post_close_callback()
  855. Add a thing to do after closing stdout; useful for housekeeping that doesn't need to hold up pageload.
  856. =cut
  857. sub add_post_close_callback {
  858. my ( $self, $cb ) = @_;
  859. push( @{ $self->{callbacks} }, $cb );
  860. }
  861. =head2 signal_restart_parent()
  862. Instruct tPSGI to reload after closing stdout.
  863. =cut
  864. sub signal_restart_parent {
  865. my ($self) = @_;
  866. $self->add_post_close_callback( \&_restart_parent );
  867. }
  868. # Instruct the parent to restart. Normally this is HUP, but nginx-unit decides to be special.
  869. # Don't do anything if running NOHUP=1, which is useful when doing bulk operations
  870. sub _restart_parent {
  871. my $parent = shift;
  872. return if $ENV{NOHUP};
  873. if ( $ENV{PSGI_ENGINE} && $ENV{PSGI_ENGINE} eq 'nginx-unit' ) {
  874. my $conf = Trog::Config->get();
  875. my $nginx_socket = $conf->param('nginx-unit.socket');
  876. my $client = HTTP::Tiny::UNIX->new();
  877. my $res = $client->request( 'GET', "http:$nginx_socket//control/applications/tcms/restart" );
  878. WARN("could not reload application (got $res->{status} from nginx-unit)!") unless $res->{status} == 200;
  879. return 1;
  880. }
  881. $parent //= getppid;
  882. kill 'HUP', $parent;
  883. }
  884. 1;