FU: HTTP date handling + fu->send_file

This commit is contained in:
Yorhel 2025-02-24 11:12:01 +01:00
parent 18e642290d
commit 8595c4ba64
2 changed files with 144 additions and 8 deletions

100
FU.pm
View file

@ -208,7 +208,7 @@ sub _monitor {
our $hdrname_re = qr/[!#\$\%&'\*\+-\.^_`\|~0-9a-zA-Z]{1,127}/;
our $method_re = qr/(?:GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)/;
our $method_re = qr/(?:HEAD|GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)/;
# rfc7230 used as reference, though strict conformance is not a goal.
# Does not limit size of headers, so not suitable for deployment in untrusted networks.
@ -303,7 +303,7 @@ sub _log_err($e) {
}
sub _do_req($c) {
local $REQ = { hdr => {} };
local $REQ = { hdr => {}, trace_start => time };
local $fu = bless {}, 'FU::obj';
$REQ->{ip} = $c->{client_sock} isa 'IO::Socket::INET' ? $c->{client_sock}->peerhost : '127.0.0.1';
@ -316,7 +316,8 @@ sub _do_req($c) {
for my $h (@before_request) { $h->() }
my $path = fu->path;
my $r = $path_routes{ fu->method }{$path};
my $method = fu->method eq 'HEAD' ? 'GET' : fu->method;
my $r = $path_routes{$method}{$path};
if ($r) { $r->() }
else {
for $r ($re_routes{ fu->method }->@*) {
@ -567,6 +568,7 @@ sub run(%conf) {
package FU::obj;
use v5.36;
use Carp 'confess';
sub fu() { $FU::fu }
@ -655,7 +657,37 @@ sub add_header($, $hdr, $val) {
sub set_header($, $hdr, $val=undef) {
_validate_header($hdr, $val);
$FU::REQ{reshdr}{ lc $hdr } = $val;
$FU::REQ->{reshdr}{ lc $hdr } = $val;
}
sub send_file($, $root, $path) {
# This also catches files with '..' somewhere in the middle of the name.
# Let's just disallow that to simplify this check, I'd err on the side of
# caution.
return if $path =~ /\.\./;
my $fn = "$root/$path";
my $m = (stat $fn)[9];
return if !defined $m;
fu->set_header('last-modified', FU::Util::httpdate_format($m));
my $ims = fu->header('if-modified-since');
$ims = FU::Util::httpdate_parse($ims) if $ims;
if ($ims && $ims > $m) {
fu->status(304);
fu->done;
}
my $ctype = FU::mime_types->{$path =~ m{\.([^/\.]+)$} ? lc $1 : ''};
{
open my $fh, '<', $fn or confess "Unable to open '$fn': $!";
local $/=undef;
my $body = <$fh>;
$ctype ||= substr($body, 0, 1024) =~ /[\x00-\x08\x0e-\x1f]/ ? 'application/octet-stream' : 'text/plain';
fu->set_body($body);
}
fu->set_header('content-type', $ctype);
fu->done;
}
sub _error_page($, $code, $title, $msg) {
@ -687,9 +719,10 @@ sub _finalize {
state $haszstd = eval { require Compress::Zstd; 1 };
my $r = $FU::REQ;
if ($r->{status} == 204) {
if ($r->{status} == 204 || $r->{status} == 304) {
delete $r->{reshdr}{'content-length'};
delete $r->{reshdr}{'content-encoding'};
delete $r->{reshdr}{'content-type'};
$r->{resbody} = '';
} else {
@ -716,7 +749,7 @@ sub _finalize {
$r->{resbody} = '' if (fu->method//'') eq 'HEAD';
}
$r->{reshdr}{'content-type'} .= '; charset=UTF-8' if FU::utf8_mimes->{$r->{reshdr}{'content-type'}};
$r->{reshdr}{'content-type'} .= '; charset=UTF-8' if FU::utf8_mimes->{ $r->{reshdr}{'content-type'}||'' };
}
sub _flush($, $sock) {
@ -729,6 +762,8 @@ sub _flush($, $sock) {
$sock->print("\r\n");
} else {
$sock->printf("HTTP/1.0 %d Hello\r\n", $r->{status});
$sock->printf("date: %s\r\n", FU::Util::httpdate_format time);
$sock->print("server: FU\r\n");
}
for my ($hdr, $val) ($r->{reshdr}->%*) {
@ -898,6 +933,21 @@ setting to enable or disable debugging features in your own code.
Enable logging of requests that took longer than C<$ms> milliseconds to
process. Can be set to 0 to disable such logging.
=item FU::mime_types
Returns a modifiable hashref that serves as a lookup table from file extension
to MIME type, used by C<< fu->send_file() >>.
=item FU::utf8_mimes
Returns a modifiable hashref listing which mime types should get a UTF-8
C<charset> parameter appended to them in the C<Content-Type> header.
=item FU::compress_mimes
Returns a modifiable hashref listing mime types for which compression makes
sense.
=item FU::monitor_path(@paths)
Add filesystem paths to be monitored for changes when running in monitor mode
@ -1150,6 +1200,42 @@ templating system or L<FU::XMLWriter>:
};
});
=item fu->send_file($root, $path)
If a file identified by C<"$root/$path"> exists, set that as response and call
C<< fu->done >>. Returns normally if the file does not exist. This method is
mainly intended to serve small static files from a directory:
FU::before_request {
# We can set custom headers before send_file()
fu->set_header('cache-control', 'max-age=31536000');
# Attempt to serve files from '/static/files'
fu->send_file('/static/files', fu->path);
# If that fails, fall back to another directory
fu->send_file('/more/static/files', fu->path);
# Otherwise, continue processing the request as normal
fu->reset;
};
C<$path> may be an untrusted string from the client, this method prevents path
traversal attacks that go below the given C<$root>. It does follow symlinks,
though.
This method loads the entire file contents in memory and does not support range
requests, so DO NOT use it to send large files. Actual web servers are much
more efficient at sending static files.
The content-type header is determined from the file extension in C<$path>,
using the configured C<FU::mime_types>. As fallback, files that look like they
might be text get C<text/plain> and binary files are served with
C<application/octet-stream>.
This method sets an appropriate C<last-modified> header and supports
conditional requests with C<if-modified-since>.
=back
I<TODO:> Setting cookies.
@ -1158,8 +1244,6 @@ I<TODO:> JSON output.
I<TODO:> Redirection responses.
I<TODO:> Sending files.
=head2 Running the Site

View file

@ -4,12 +4,14 @@ use v5.36;
use FU::XS;
use Carp 'confess';
use Exporter 'import';
use POSIX ();
use experimental 'builtin';
our @EXPORT_OK = qw/
json_format json_parse
utf8_decode uri_escape uri_unescape
query_decode query_encode
httpdate_format httpdate_parse
fdpass_send fdpass_recv
/;
@ -60,6 +62,31 @@ sub query_encode :prototype($) ($o) {
} sort keys %$o;
}
my @httpmonths = qw/Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec/;
my %httpmonths = map +($httpmonths[$_], $_), 0..11;
my @httpdays = qw/Sun Mon Tue Wed Thu Fri Sat/;
my $httpdays = '(?:'.join('|', @httpdays).')';
sub httpdate_format :prototype($) ($time) {
my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday) = gmtime $time;
sprintf '%s, %02d %s %d %02d:%02d:%02d GMT',
$httpdays[$wday], $mday, $httpmonths[$mon], $year+1900, $hour, $min, $sec;
}
sub httpdate_parse :prototype($) ($str) {
return if $str !~ /^$httpdays, ([0-9]{2}) ([A-Z][a-z]{2}) ([0-9]{4}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/;
my ($mday, $mon, $year, $hour, $min, $sec) = ($1, $httpmonths{$2}, $3, $4, $5, $6);
return if !defined $mon;
# mktime() interprets the broken down time as our local timezone,
# which is utter garbage. But we can work around that by subtracting the
# time offset between localtime and gmtime around the given date. Might be
# off for a few hours around DST changes, but ugh.
my $mktime = POSIX::mktime($sec, $min, $hour, $mday, $mon, $year-1900);
$mktime + (POSIX::mktime(localtime $mktime) - POSIX::mktime(gmtime $mktime));
}
1;
__END__
@ -272,6 +299,31 @@ then encoded.
=back
=head2 HTTP Date Formatting
The HTTP date format is utter garbage, but with the right tools it doesn't
require I<too> much code to work with.
=over
=item httpdate_format($time)
Convert the given seconds-since-Unix-epoch C<$time> into a HTTP date string.
=item httpdate_parse($str)
Converts the given HTTP date string into a seconds-since-Unix-epoch integer.
This function is very strict about its input and only accepts "IMF-fixdate" as
per L<RFC7231|https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1>, which is
what every sensible implementation written in the past decade uses.
This function plays fast and loose with timezone conversions, the parsed
timestamp I<might> be off by an hour or so for a few hours around a DST change.
This will not happen if your local timezone is UTC.
=back
=head2 File Descriptor Passing
UNIX sockets (see L<IO::Socket::UNIX>) have the fancy property of letting you