diff --git a/FU.pm b/FU.pm index 565d7ac..41e886b 100644 --- a/FU.pm +++ b/FU.pm @@ -2,18 +2,27 @@ package FU 0.1; use v5.36; use Carp 'confess'; -sub import($caller, @opt) { - no strict 'refs'; - *{$caller.'::fu'} = \&fu; - # TODO: Check env/cli args and setup master listen process here, before everything else is loaded. +sub import($pkg, @opt) { + my %opt = map +($_,1), @opt; + + _spawn() if $opt{-spawn}; + + no strict 'refs'; + my $c = caller; + *{$c.'::fu'} = \&fu; } -sub debug() { state $v = 0 } -sub log_slow_pages() { state $v = 0 } -sub log_queries() { state $v = 0 } -sub max_request_body() { state $v = 10*1024*1024 } +our $REQ = {}; # Internal request-local data +our $fu = bless {}, 'FU::obj'; # App request-local data +sub fu() { $fu } + + +sub debug :lvalue () { state $v = 0 } +sub log_slow_pages :lvalue () { state $v = 0 } +sub log_queries :lvalue () { state $v = 0 } +sub max_request_body :lvalue () { state $v = 10*1024*1024 } sub mime_types() { state $v = {qw{ 7z application/x-7z-compressed @@ -86,7 +95,7 @@ my %re_routes; sub _add_route($path, $sub, $method) { if (ref $path eq 'REGEXP' || ref $path eq 'Regexp') { - push $re_routes{$method}->@*, [ $path, $sub ]; + push $re_routes{$method}->@*, [ qr/^$path$/, $sub ]; } elsif (!ref $path) { confess("A route has already been registered for $method $path") if $path_routes{$method}{$path}; $path_routes{$method}{$path} = $sub; @@ -104,41 +113,72 @@ sub patch :prototype($&) { push @_, 'PATCH'; &_add_route; } sub query :prototype($&) { push @_, 'QUERY'; &_add_route; } -our $REQ = {}; -our $fu = bless {}, 'FU::obj'; -sub fu() { $fu } +sub _err_500 { + fu->_error_page(500, '500 - Extraterrestrial Server Error', <<~_); + Ouch! Something went wrong on the server. Perhaps a misconfiguration, + perhaps a bug, or perhaps just a temporary issue caused by regular + maintenance or maybe even alien interference. Details of this error have + been written to a log file. If the issue persists, please contact the site + admin to let them know that they might have some fixing to do. + _ +}; +my %onerr = ( + 400 => sub { + fu->_error_page(400, '400 - Bad Request', 'The server was not happy with your offer.'); + }, + 404 => sub { + fu->_error_page(404, '404 - Page Not Found', <<~_); + Whatever it is you were looking for, this probably isn't it. +
Unless you were looking for an error page, in which case: + Congratulations! + _ + }, + 413 => sub { + fu->_error_page(413, '413 - Request Entity Too Large', <<~_); + That's an odd way of saying that you were probably trying to upload a large + file. Too large, in fact, for the server to handle. If you believe this + error to be mistaken, you can ask the site admin to increase the maximum + allowed upload size. + _ + }, + 500 => \&_err_500, + '*' => sub($code, @) { + fu->_error_page($code, "$code - Unknown error", 'Welp, something went wrong processing your request.'); + }, +); +sub on_error :prototype($&) { $onerr{$_[0]} = $_[1] } -sub _handle_request { - my $eval = eval { - 1; - }; - if ($REQ->{txn}) { - $REQ->{txn}->commit if $eval; - $REQ->{txn}->abort if !$eval; - delete $REQ->{txn}; - } +sub _decode_utf8 { + fu->error(400, 'Invalid UTF-8 in request') if !utf8::decode($_[0]); + # Disallow any control codes, except for x09 (tab), x0a (newline) and x0d (carriage return) + fu->error(400, 'Invalid control character in request') if $_[0] =~ /[\x00-\x08\x0b\x0c\x0e-\x1f]/; } +our $hdrname_re = qr/[!#\$\%&'\*\+-\.^_`\|~0-9a-zA-Z]+/; # rfc7230 used as reference, though strict conformance is not a goal. -# Does not limit size of headers, not suitable for deployment in untrusted networks. +# Does not limit size of headers, so not suitable for deployment in untrusted networks. sub _http_read_request($sock, $req) { local $/ = "\r\n"; my $line = $sock->getline; - die "Client disconnect before request was read" if !defined $line; - die if $line !~ /^(GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)\s+(\S+)\s+HTTP\/1\.[01]\r\n$/; + fu->error(400, 'Client disconnect before request was read') if !defined $line; + fu->error(400, 'Invalid request') if $line !~ /^(GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)\s+(\S+)\s+HTTP\/1\.[01]\r\n$/; $req->{method} = $1; - $req->{uri} = $2 =~ s{^https?://[^/]+/}{/}r; + ($req->{path}, $req->{qs}) = split /\?/, $2 =~ s{^https?://[^/]+/}{/}r, 2; + $req->{path} =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + _decode_utf8 $req->{path}; + _decode_utf8 $req->{qs} if defined $req->{qs}; while (1) { # Turns out header line folding has been officially deprecated, so I'm # going to be lazy and only support single-line headers. $line = $sock->getline; - die "Client disconnect before request was read" if !defined $line; + fu->error(400, 'Client disconnect before request was read') if !defined $line; last if $line eq "\r\n"; - die if $line !~ /^([!#\$\%&'\*\+-\.^_`\|~0-9a-zA-Z]+):\s*(.+)\r\n$/; + fu->error(400, 'Invalid request header syntax') if $line !~ /^($hdrname_re):\s*(.+)\r\n$/; my($hdr, $val) = (lc $1, $2 =~ s/\s*$//r); + _decode_utf8 $val; if (exists $req->{hdr}{$hdr}) { $req->{hdr}{$hdr} .= ($hdr eq 'cookie' ? '; ' : ', ') . $val; } else { @@ -146,50 +186,157 @@ sub _http_read_request($sock, $req) { } } - die if $req->{hdr}{'transfer-encoding'}; # Can't do that + fu->error(400, 'Unexpected Transfer-Encoding request header') if $req->{hdr}{'transfer-encoding'}; my $len = $req->{hdr}{'content-length'} // 0; - die if $len !~ /^(?:0|[1-9][0-9]*)$/; - die if $len > max_request_body; # TODO: Appropriate response code + fu->error(400, 'Invalid Content-Length request header') if $len !~ /^(?:0|[1-9][0-9]*)$/; + fu->error(413, 'Request body too large') if $len > max_request_body; $req->{body} = ''; while ($len > 0) { my $r = $sock->read($req->{body}, $len, -1); - die $! if !defined $r; - die "Client disconnect before request was read" if !$r; + fu->error(400, 'Client disconnect before request was read') if !$r } } -sub run { - # TODO: Listen options - # TODO: FastCGI - # TODO: Autorestarting master process - require IO::Socket::INET; - my $listen = IO::Socket::INET->new( - Listen => 5, ReuseAddr => 1, Proto => 'tcp', - LocalAddr => '0.0.0.0:3000', + + +my $run_config = undef; + +sub _is_done($e) { ref $@ eq 'FU::err' && $@->[0] == 200 } + +sub _log_err($e) { + return if !$e; + return if !debug && ref $@ eq 'FU::err' && $@->[0] != 500; + warn $e =~ /\n$/ ? $e : "$e\n"; +} + +sub _do_req($sock) { + # TODO: check for changes if $run_config->{monitor} + local $REQ = {}; + local $fu = bless {}, 'FU::obj'; + + $REQ->{ip} = $sock isa 'IO::Socket::INET' ? $sock->peerhost : '127.0.0.1'; + fu->reset; + + my $ok = eval { + _http_read_request($sock, $REQ); + + for my $h (@before_request) { $h->() } + + my $path = fu->path; + my $r = $path_routes{ fu->method }{$path}; + if ($r) { $r->() } + else { + for $r ($re_routes{ fu->method }->@*) { + if($path =~ $r->[0]) { + $r->[1]->(@{^CAPTURE}); + fu->done; + } + } + fu->error(404); + } + 1; + }; + my $err = $ok || _is_done($@) ? undef : $@; + _log_err $err; + + for my $h (@after_request) { + $ok = eval { $h->(); 1 }; + _log_err $@ if !$ok; + $err = $@ if !$err && !$ok && !_is_done($@); + } + + # Commit transaction, if we have one that's not done yet. + if (!$err && $REQ->{txn} && $REQ->{txn}->status ne 'done' && !eval { $REQ->{txn}->commit; 1 }) { + _log_err "Transaction commit failed: $@"; + $err = $@; + } + + if ($err) { + fu->reset; + my($code, $msg) = ref $@ eq 'FU::err' ? $@->@* : (500, $err); + eval { + ($onerr{$code} || $onerr{'*'})->($code, $msg); + 1; + } || _err_500(); + } + + fu->_flush($sock); + $sock->close; + + exit if $run_config->{max_reqs} && --$run_config->{max_reqs}; +} + +sub _spawn { + return if $run_config && !@_; # already checked if we need to spawn + + $run_config = $_[0] || do { + my %c = ( + http => $ENV{FU_HTTP} // '127.0.0.1:3000', + fcgi => $ENV{FU_FCGI}, + proc => $ENV{FU_PROC} // 1, + monitor => $ENV{FU_MONITOR}, + max_reqs => $ENV{FU_MAX_REQS}, + ); + debug = 1 if $ENV{FU_DEBUG}; + for (@ARGV) { + $c{http} = $1 if /^--http=(.+)$/; + $c{fcgi} = $1 if /^--fcgi=(.+)$/; + $c{proc} = $1 if /^--proc=([0-9]+)$/; + $c{monitor} = 1 if /^--monitor$/; + $c{monitor} = 0 if /^--no-monitor$/; + $c{max_reqs} = $1 if /^--max-reqs=([0-9]+)$/; + debug = 1 if /^--debug$/; + debug = 0 if /^--no-debug$/; + } + \%c; + }; + + # Single process, no need for a supervisor + my $need_supervisor = $run_config->{proc} > 1 || $run_config->{monitor} || $run_config->{max_reqs}; + return if !@_ && !$need_supervisor; + + require IO::Socket; + my $addr = $run_config->{fcgi} || $run_config->{http}; + my $listen = IO::Socket->new( + Listen => 5, + Type => IO::Socket::SOCK_STREAM(), + $addr =~ m{^(unix:|/)(.+)$} ? do { + my $path = ($1 eq '/' ? '/' : '').$2; + unlink $path if -S $path; + +(Domain => IO::Socket::AF_UNIX(), Local => $path) + } : ( + Domain => IO::Socket::AF_INET(), + ReuseAddr => 1, + Proto => 'tcp', + LocalAddr => $addr, + ) ) or die "Unable to create listen socket: $!\n"; - while (my $sock = $listen->accept) { - local $REQ = {}; - local $fu = bless {}, 'FU::obj'; + # TODO: Spawn supervisor + print "Listening on $addr\n" if debug; - $REQ->{sock} = $sock; - $REQ->{ip} = $sock->peerhost; - if (!eval { _http_read_request($sock, $REQ) }) { - # TODO: Return a 400 - } else { - _handle_request - } - $sock->close; + while (my $sock = $listen->accept) { + _do_req $sock; } } +sub run(%conf) { + confess "FU::run() called with configuration options, but FU has already been loaded with -spawn" if keys %conf; + # Clean up any state we may have accumulated during initialization. + $REQ = {}; + $fu = bless {}, 'FU::obj'; + _spawn(keys %conf ? \%conf : undef); +} + + package FU::obj; -*fu = \&FU::fu; +use Carp 'confess'; -sub debug { FU::debug } +sub fu() { $FU::fu } +sub debug :lvalue { FU::debug } sub db_conn { $FU::DB || FU::_connect_db } @@ -209,6 +356,110 @@ sub sql { fu->db->q(@_) } + +# Request information methods + +sub path { $FU::REQ->{path} } +sub method { $FU::REQ->{method} } +sub header($, $h) { $FU::REQ->{hdr}{ lc $h } } +sub headers { $FU::REQ->{hdr} } +sub ip { $FU::REQ->{ip} } + + + + +# Response generation methods + +sub done { die bless [200,'Done'], 'FU::err' } +sub error($,$code,$msg=$code) { die bless [$code,$msg], 'FU::err' } + +sub reset { + my $r = $FU::REQ; + $r->{status} = 200; + $r->{reshdr} = [ + 'content-type', 'text/html; charset=UTF-8', + ]; + $r->{resbody} = ''; +} + +sub status($, $code) { $FU::REQ->{status} = $code } +sub set_body($, $data) { $FU::REQ->{resbody} = $data } # TODO: replace with a regular 'print' + + +sub _validate_header($hdr, $val) { + confess "Invalid response header '$hdr'" if $hdr !~ /^$FU::hdrname_re$/; + confess "Invalid attempt to set response header containing a newline" if $val =~ /[\r\n]/; +} + +sub add_header($, $hdr, $val) { + _validate_header($hdr, $val); + push $FU::REQ->{reshdr}->@*, lc $hdr, $val; +} + +sub set_header($, $hdr, $val=undef) { + _validate_header($hdr, $val); + $hdr = lc $hdr; + # Not very efficient *shrug* + my @r; + for my ($ihdr, $ival) ($FU::REQ->{reshdr}->@*) { + push @r, $ihdr, $ival if $ihdr ne $hdr; + } + push @r, $hdr, $val if defined $val; + $FU::REQ->{reshdr} = \@r; +} + +sub _error_page($, $code, $title, $msg) { + fu->reset; + fu->status($code); + my $body = <<~_; + + + + + + $title + + +

$title

+

$msg

+ + + _ + utf8::encode($body); + fu->set_body($body); +} + +sub _flush($, $sock) { + my $r = $FU::REQ; + $sock->printf("HTTP/1.0 %d Hello\r\n", $r->{status}); + + if ($r->{status} == 204) { + fu->set_header('content-length', undef); + fu->set_header('content-encoding', undef); + } else { + fu->set_header('content-length', length $r->{resbody}); + } + for my ($hdr, $val) ($r->{reshdr}->@*) { + utf8::encode($hdr); + utf8::encode($val); + $sock->printf("%s: %s\r\n", $hdr, $val); + } + $sock->print("\r\n"); + + $sock->print($r->{resbody}) if fu->method ne 'HEAD' && $r->{status} != 204; + $sock->flush; +} + + + +package FU::err; + +use overload '""' => sub { sprintf "FU exception code %d: %s", $_[0][0], $_[0][1] }; + + 1; __END__