FileHandler.pm 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. package Trog::FileHandler;
  2. use v5.36;
  3. use re '/aa';
  4. use POSIX qw{strftime};
  5. use IO::Compress::Gzip;
  6. use Time::HiRes qw{tv_interval};
  7. use Trog::Log qw{:all};
  8. use Trog::Vars;
  9. use Trog::Utils;
  10. =head2 serve
  11. Serve a file, with options to stream and cache the output.
  12. =cut
  13. sub serve ( $fullpath, $path, $start, $streaming, $ranges, $last_fetch = 0, $deflate = 0 ) {
  14. my $ft = Trog::Utils::mime_type($path);
  15. $ft ||= $Trog::Vars::content_types{text};
  16. my $ct = 'Content-type';
  17. my @headers = ( $ct => $ft );
  18. #TODO use static Cache-Control for everything but JS/CSS?
  19. push( @headers, 'Cache-control' => $Trog::Vars::cache_control{revalidate} );
  20. push( @headers, 'Accept-Ranges' => 'bytes' );
  21. my $mt = ( stat($path) )[9];
  22. my $sz = ( stat(_) )[7];
  23. my @gm = gmtime($mt);
  24. my $now_string = strftime( "%a, %d %b %Y %H:%M:%S GMT", @gm );
  25. my $code = $mt > $last_fetch ? 200 : 304;
  26. push( @headers, "Last-Modified" => $now_string );
  27. push( @headers, 'Vary' => 'Accept-Encoding' );
  28. if ( open( my $fh, '<', $path ) ) {
  29. return _range( $fullpath, $fh, $ranges, $sz, @headers ) if @$ranges && $streaming;
  30. # Transfer-encoding: chunked
  31. return sub {
  32. my $responder = shift;
  33. push( @headers, 'Content-Length' => $sz );
  34. my $writer = $responder->( [ $code, \@headers ] );
  35. while ( $fh->read( my $buf, $Trog::Vars::CHUNK_SIZE ) ) {
  36. $writer->write($buf);
  37. }
  38. close $fh;
  39. $writer->close;
  40. }
  41. if $streaming && $sz > $Trog::Vars::CHUNK_SIZE;
  42. #Return data in the event the caller does not support deflate
  43. if ( !$deflate ) {
  44. push( @headers, "Content-Length" => $sz );
  45. INFO("GET 200 $sz $fullpath");
  46. # Append server-timing headers
  47. my $tot = tv_interval($start) * 1000;
  48. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  49. return [ $code, \@headers, $fh ];
  50. }
  51. #Compress everything less than 1MB
  52. push( @headers, "Content-Encoding" => "gzip" );
  53. my $dfh;
  54. IO::Compress::Gzip::gzip( $fh => \$dfh );
  55. print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
  56. push( @headers, "Content-Length" => length($dfh) );
  57. INFO( "GET 200 " . length($dfh) . " $fullpath" );
  58. # Append server-timing headers
  59. my $tot = tv_interval($start) * 1000;
  60. push( @headers, 'Server-Timing' => "file;dur=$tot" );
  61. return [ $code, \@headers, [$dfh] ];
  62. }
  63. INFO("GET 403 23 $fullpath");
  64. return [ 403, [ $ct => $Trog::Vars::content_types{text} ], ["STAY OUT YOU RED MENACE"] ];
  65. }
  66. sub _range ( $fullpath, $fh, $ranges, $sz, %headers ) {
  67. # Set mode
  68. my $primary_ct = "Content-Type: $headers{'Content-type'}";
  69. my $is_multipart = scalar(@$ranges) > 1;
  70. if ($is_multipart) {
  71. $headers{'Content-type'} = "multipart/byteranges; boundary=$Trog::Vars::CHUNK_SEP";
  72. }
  73. my $code = 206;
  74. my $fc = '';
  75. # Calculate the content-length up-front. We have to fix unspecified lengths first, and reject bad requests.
  76. foreach my $range (@$ranges) {
  77. $range->[1] //= $sz - 1;
  78. INFO("GET 416 31 $fullpath");
  79. return [ 416, [%headers], ["Requested range not satisfiable"] ] if $range->[0] > $sz || $range->[0] < 0 || $range->[1] < 0 || $range->[0] > $range->[1];
  80. }
  81. $headers{'Content-Length'} = List::Util::sum( map { my $arr = $_; $arr->[1] + 1, -$arr->[0] } @$ranges );
  82. #XXX Add the entity header lengths to the value - should hash-ify this to DRY
  83. if ($is_multipart) {
  84. foreach my $range (@$ranges) {
  85. $headers{'Content-Length'} += length("$fc--$Trog::Vars::CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n");
  86. $fc = "\n";
  87. }
  88. $headers{'Content-Length'} += length("\n--$Trog::Vars::CHUNK_SEP\--\n");
  89. $fc = '';
  90. }
  91. #TODO log these requests with their length correctly
  92. return sub {
  93. my $responder = shift;
  94. my $writer;
  95. foreach my $range (@$ranges) {
  96. $headers{'Content-Range'} = "bytes $range->[0]-$range->[1]/$sz" unless $is_multipart;
  97. $writer //= $responder->( [ $code, [%headers] ] );
  98. $writer->write("$fc--$Trog::Vars::CHUNK_SEP\n$primary_ct\nContent-Range: bytes $range->[0]-$range->[1]/$sz\n\n") if $is_multipart;
  99. $fc = "\n";
  100. my $len = List::Util::min( $sz, $range->[1] + 1 ) - $range->[0];
  101. $fh->seek( $range->[0], 0 );
  102. while ($len) {
  103. $fh->read( my $buf, List::Util::min( $len, $Trog::Vars::CHUNK_SIZE ) );
  104. $writer->write($buf);
  105. # Adjust for amount written
  106. $len = List::Util::max( $len - $Trog::Vars::CHUNK_SIZE, 0 );
  107. }
  108. }
  109. $fh->close();
  110. $writer->write("\n--$Trog::Vars::CHUNK_SEP\--\n") if $is_multipart;
  111. $writer->close;
  112. };
  113. }
  114. 1;