diff --git a/ChangeLog b/ChangeLog
index 8a0bb73..0774d6b 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,96 @@
+1.4 - 2026-01-10
+ - FU::Pg: rename q() and Q() to sql() and SQL() (old names still work)
+ - FU: Improve handling of EPIPE when writing FastCGI response
+ - FU: Log unclean worker process shutdown
+ - FU: Fix warning when parsing empty cookie values
+ - Misc doc fixes
+
+1.3 - 2025-09-04
+ - FU::Validate: Scalar validations now reject control characters by default
+ - FU::Validate: Add `allow_control` option to override above behavior
+ - FU::Util: JSON and URI parsing now always permit control characters
+ - FU::Util: More strict UTF-8 validation on path & URI decoding
+ - FU::Util: Deprecate `decode_utf8()`
+ - FU::Util: Deprecate `allow_control` option in `json_parse()`
+
+1.2 - 2025-07-06
+ - FU::Pg: Throw error on non-boolean-looking Perl values for boolean bind
+ parameters
+ - FU: Improve setting process status during startup
+
+1.1 - 2025-06-07
+ - FU::SQL: Add IDENT function and `quote_identifier` option
+ - FU::Pg: Set appropriate `quote_identifier` for `$conn->Q()`
+ - FU: Improve `--monitor` file change detection
+ - FU::XMLWriter: Disallow stringification of bare Perl references
+ - FU::Util::json_parse(): Disallow control characters in strings, add
+ `allow_control` option to revert to old behavior.
+ - Some doc fixes
+
+1.0 - 2025-05-11
+ - FU::Util: Fix parsing of empty sections in query_decode()
+ - FU::Util: Fix buffer overflow in json_format() float formatting
+ - FU::Util: Reject `0x1f` in utf8_decode()
+ - FU::Pg: Add perl<->text and bin<->text type conversion methods
+ - FU::Validate: Improved error messages
+ - FU::MultipartFormData: Various parser fixes
+ - FU: Include request body in verbose error logs
+ - FU: Add fu->log_verbose()
+ - FU: Extend debug_info pages with request body, response body, 'fu'
+ object dump, expandable query parameters and interpolated SQL queries
+ - FU: Improve styling of debug_info pages
+ - FU: Preserve headers on fu->redirect
+ - FU: Ignore HTTP_CONTENT_LENGTH and HTTP_CONTENT_TYPE FastCGI parameters
+ - FU: Suppress warning about missing files in FU::monitor_path
+ - FU: Reject hash character and newlines in request path
+ - Fix creating read-only undef/true/false in json_parse() and FU::Pg
+ - Benchmark updates
+
+0.5 - 2025-04-24
+ - FU::Util: Set O_CLOEXEC on fds received through `fdpass_recv()`
+ - FU::Util: Fix interpretation of false options in `json_format()` and
+ `json_parse()`
+ - FU::Validate: Add `coerce()` and `empty()` utility methods
+ - FU::Validate: Limit values of int/uint input to 64 bits
+ - FU::Validate: Normalize num/int/uint inputs to Perl numeric types
+ - FU::Pg: Add `escape_literal()` and `escape_identifier()` methods
+ - FU::Pg: Use less memory for `kvv()`, `kva()` and `kvh()` methods
+ - FU::Pg: Disallow chaining of `cache()`, `text()`, `text_params()` and
+ `text_results()` methods on connection and transaction objects
+ - FU: Throw and catch FU::Validate errors without wrapping in `fu->error()`
+ - FU: Add `-progname` option and add diagnostics to process names
+ - FU: Whole bunch of misc fixes
+ - Doc fixes
+ - Fix nul-termination of some XS-created strings
+
+0.4 - 2025-03-19
+ - FU::Validate: Support arrayref schemas
+ - FU::Validate: Rename 'values' option to 'elems'
+ - FU::Validate: Repurpose 'values' option to validate hash values
+ - FU::Validate: Merge nested 'elems', 'keys' and 'values' schemas during compile()
+ - FU::Validate: Rename 'scalar' to 'accept_scalar'
+ - FU::Validate: Add 'accept_array' option
+ - FU::Util: Add 'html_safe' option to json_format()
+ - FU::Util: Add gzip_compress() wrapper for libdeflate.so, zlib-ng.so or zlib.so
+ - FU::Util: Add brotli_compress() wrapper for libbrotlienc.so
+ - FU: Consistency fixes for fu->json() and fu->formdata()
+ - FU: Add fu->cookie() and fu->set_cookie()
+ - FU: Add support for brotli output compression
+ - FU: Use gzip_compress() for faster gzip output compression
+
+0.3 - 2025-03-10
+ - FU::Validate: Change API, ->validate() now returns data or throws error on failure
+ - FU::Validate: Rename 'rmwhitespace' to 'trim'
+ - FU::Validate: Support (more) human-readable error messages
+ - FU::Pg: Add support for COPY operations
+ - FU::Pg: Support types with dynamic OIDs
+ - FU: Add support for reading multipart/form-data
+ - FU: Add convenience methods for reading and writing JSON
+ - FU: Fix error in handling a 400
+ - FU::MultipartFormData: New helper module
+ - Fix some tests
+ - Some doc improvements
+
0.2 - 2025-02-28
- FU: Add debug_info web interface
- FU: Add fu->denied and fu->notfound methods
diff --git a/FU.pm b/FU.pm
index 78c0fce..cb73e10 100644
--- a/FU.pm
+++ b/FU.pm
@@ -1,21 +1,29 @@
-package FU 0.2;
+package FU 1.4;
use v5.36;
use Carp 'confess', 'croak';
use IO::Socket;
use POSIX ();
-use Time::HiRes 'time';
+use Time::HiRes 'time', 'clock_gettime', 'CLOCK_MONOTONIC';
use FU::Log 'log_write';
use FU::Util;
+use FU::Validate;
+my $procname;
+my $scriptpath = $0;
sub import($pkg, @opt) {
my $c = caller;
no strict 'refs';
*{$c.'::fu'} = \&fu;
+ my $spawn;
for (@opt) {
- if ($_ eq '-spawn') { _spawn() }
+ if (ref $procname eq 'FU::ARG') { $procname = $_ }
+ elsif ($_ eq '-procname') { $procname = bless {}, 'FU::ARG' }
+ elsif ($_ eq '-spawn') { $spawn = 1; }
else { croak "Unknown import option: '$_'" }
}
+ croak "Missing argument for -procname option" if ref $procname eq 'FU::ARG';
+ _spawn() if $spawn;
}
@@ -113,15 +121,29 @@ sub query_trace($st,@) {
$REQ->{trace_nsqldirect}++ if !defined $st->prepare_time;
$REQ->{trace_sqlexec} += $st->exec_time;
$REQ->{trace_sqlprep} += $st->prepare_time if $st->prepare_time;
- push $REQ->{trace_sql}->@*, {
- query => $st->query, nrows => $st->nrows,
- param_types => $st->param_types, param_values => $st->param_values,
- exec_time => $st->exec_time, prepare_time => $st->prepare_time,
- } if FU::debug;
+ if (FU::debug) {
+ my $t = $st->param_types;
+ my $v = $st->param_values;
+ my $txt = $st->get_text_params;
+ push $REQ->{trace_sql}->@*, {
+ query => $st->query, nrows => $st->nrows,
+ exec_time => $st->exec_time, prepare_time => $st->prepare_time,
+ # Store the binary value when we're in binary params mode, that way
+ # we don't have to keep a reference to the original perl value and
+ # we can defer & batch the conversion to text.
+ params => [ map +{
+ type => $t->[$_],
+ !defined $v->[$_] ? (text => undef) :
+ $txt ? (text => "$v->[$_]")
+ : (bin => $DB->perl2bin($t->[$_], $v->[$_]))
+ }, 0..$#$v ],
+ };
+ }
}
sub _connect_db {
$DB = ref $INIT_DB eq 'CODE' ? $INIT_DB->() : FU::Pg->connect($INIT_DB);
$DB->query_trace(\&query_trace);
+ $DB
}
sub init_db($info) {
require FU::Pg;
@@ -195,25 +217,20 @@ sub monitor_path { push @monitor_paths, @_ }
sub monitor_check :prototype(&) { $monitor_check = $_[0] }
sub _monitor {
- state %data;
return 1 if $monitor_check && $monitor_check->();
require File::Find;
eval {
File::Find::find({
- wanted => sub {
- my $m = (stat)[9];
- $data{$_} //= $m;
- die if $m > $data{$_};
- },
+ wanted => sub { die if (-M) < 0 },
no_chdir => 1
- }, $0, values %INC, @monitor_paths);
+ }, grep -e, $scriptpath, values %INC, @monitor_paths);
0
} // 1;
}
-our $debug_info = [];
+our $debug_info = {};
sub debug_info($path, $storage=undef, $history=100) {
$debug_info = { path => $path, storage => $storage, history => $history }
}
@@ -253,8 +270,9 @@ sub _read_req_http($sock, $req) {
$req->{body} = '';
while ($len > 0) {
- my $r = $sock->read($req->{body}, $len, -1);
- fu->error(400, 'Client disconnect before request was read') if !$r
+ my $r = $sock->read($req->{body}, $len, length $req->{body});
+ fu->error(400, 'Client disconnect before request was read') if !$r;
+ $len -= $r;
}
}
@@ -274,7 +292,8 @@ sub _read_req($c) {
: $r == -2 ? "I/O error while reading from FastCGI socket\n"
: $r == -3 ? "FastCGI protocol error\n"
: $r == -4 ? "Too long FastCGI parameter\n"
- : $r == -5 ? "Too long request body\n" : undef if $r != -7;
+ : $r == -5 ? "Too long request body\n"
+ : $r == -8 ? "I/O error while writing to FastCGI socket\n" : undef if $r != -7;
delete $c->{fcgi_obj};
fu->error(-1);
}
@@ -288,36 +307,33 @@ sub _read_req($c) {
# The HTTP reader above and the FastCGI XS reader operate on bytes.
# Decode these into Unicode strings and check for special characters.
- eval { FU::Util::utf8_decode($_); 1} || fu->err(400, $@)
+ eval { FU::Util::utf8_decode($_); 1} || fu->error(400, $@)
for ($REQ->{path}, $REQ->{qs}, values $REQ->{hdr}->%*);
+ fu->error(400, 'Invalid character in path') if $REQ->{path} =~ /#/; # Some bots don't correctly split off the fragment
($REQ->{path}, my $qs) = split /\?/, $REQ->{path}//'', 2;
$REQ->{qs} //= $qs;
- $REQ->{path} = FU::Util::uri_unescape($REQ->{path});
+ eval { $REQ->{path} = FU::Util::uri_unescape($REQ->{path}); FU::Util::check_control($REQ->{path}); 1; } || fu->error(400, $@);
+ fu->error(400, 'Invalid character in path') if $REQ->{path} =~ /[\r\n\t]/; # There are plenty other questionable characters, but newlines and tabs are definitely out
}
-sub _is_done($e) { ref $@ eq 'FU::err' && $@->[0] == 200 }
+sub _is_done($e) { ref $e eq 'FU::err' && $e->[0] == 200 }
sub _log_err($e) {
return if !$e;
- return if !debug && ref $@ eq 'FU::err' && $@->[0] != 500;
- if (!$REQ->{full_err} && (ref $@ ne 'FU::err' || $@->[0] == 500)) {
- $REQ->{full_err}++;
- $e =~ s/^\s+//;
- $e =~ s/\s+$//;
- log_write join "\n",
- 'IP: '.($REQ->{ip}||'-'),
- 'Headers:', (map " $_: $REQ->{hdr}{$_}", sort keys $REQ->{hdr}->%*),
- 'ERROR:'.($e =~ s/(^|\n)/\n /rg);
- # TODO: decoded body, if we have that.
- } else {
- log_write $e;
- }
+ my $crit = $e isa 'FU::err' ? $e->[0] == 500 : !($e isa 'FU::Validate::err');
+ return if !debug && !$crit;
+ return fu->log_verbose($e) if $crit;
+ log_write $e;
}
sub _do_req($c) {
- local $REQ = { hdr => {}, trace_start => time, trace_id => sprintf('%010x%08x%04x', int time, $$, int rand 1<<16) };
+ local $REQ = {
+ hdr => {},
+ trace_start => clock_gettime(CLOCK_MONOTONIC),
+ trace_id => sprintf('%012x%06x%04x', int(time*10000) % (1<<(12*4)), $$ % (1<<(6*4)), int rand 1<<16)
+ };
local $fu = bless {}, 'FU::obj';
$REQ->{ip} = $c->{client_sock} isa 'IO::Socket::INET' ? $c->{client_sock}->peerhost : '127.0.0.1';
@@ -325,7 +341,7 @@ sub _do_req($c) {
my $ok = eval {
_read_req $c;
- $REQ->{trace_start} = time;
+ $REQ->{trace_start} = clock_gettime(CLOCK_MONOTONIC);
my $path = fu->path;
my $method = fu->method eq 'HEAD' ? 'GET' : fu->method;
@@ -374,17 +390,24 @@ sub _do_req($c) {
}
if ($err) {
- my($code, $msg) = ref $err eq 'FU::err' ? $err->@* : (500, $err);
+ my($code, $msg) = $err isa 'FU::err' ? @$err : $err isa 'FU::Validate::err' ? (400, $err) : (500, $err);
fu->reset;
fu->status($code);
- eval {
- ($onerr{$code} || $onerr{500})->($code, $msg);
- 1;
- } || _err_500();
+ my $ok = eval { ($onerr{$code} || $onerr{500})->($code, $msg) };
+ if (!$ok && !_is_done($@)) {
+ _log_err $@;
+ _err_500();
+ }
}
- $REQ->{trace_end} = time;
- fu->_flush($c->{fcgi_obj} || $c->{client_sock});
+ $REQ->{trace_end} = clock_gettime(CLOCK_MONOTONIC);
+ eval {
+ fu->_flush($c->{fcgi_obj} || $c->{client_sock});
+ 1;
+ } || do {
+ log_write "Error writing response: $@\n";
+ $c->{client_sock} = $c->{fcgi_obj} = undef;
+ };
if (debug && $REQ->{trace_id} && $debug_info->{history} && $debug_info->{storage}) {
require FU::DebugImpl;
@@ -392,19 +415,20 @@ sub _do_req($c) {
}
my $proc_ms = ($REQ->{trace_end} - $REQ->{trace_start}) * 1000;
- log_write(sprintf "%.0fms%s %s-%s %s-%d\n", $proc_ms,
+ log_write(sprintf "%.0fms%s %s-%s %d-%s\n", $proc_ms,
$REQ->{trace_nsql} ?
sprintf ' (sql %.0f+%.0fms, %d/%d/%d)',
($REQ->{trace_sqlexec}||0)*1000, ($REQ->{trace_sqlprep}||0)*1000,
$REQ->{trace_nsqldirect}||0, $REQ->{trace_nsqlprep}||0, $REQ->{trace_nsql} : '',
$REQ->{status}, ($REQ->{reshdr}{'content-type'}//'-') =~ s/;.+$//r,
- $REQ->{reshdr}{'content-encoding'}//'bytes', length($REQ->{resbody}),
+ length($REQ->{resbody}), substr($REQ->{reshdr}{'content-encoding'}//'r', 0, 1)
) if FU::debug || $proc_ms > (FU::log_slow_reqs||1e10);
}
sub _run_loop($c) {
my $stop = 0;
+ my $count = 0;
local $SIG{HUP} = 'IGNORE';
local $SIG{TERM} = $SIG{INT} = sub { $stop = 1 };
@@ -414,7 +438,13 @@ sub _run_loop($c) {
exit;
}
+ my sub setstate($state) {
+ $0 = sprintf "%s: %s [#%d%s]", $procname, $state, $count, $c->{max_reqs} ? "/$c->{max_reqs}" : '' if $procname;
+ }
+
while (!$stop) {
+ setstate 'idle';
+
$c->{client_sock} ||= $c->{listen_sock}->accept || next;
$c->{fcgi_obj} ||= $c->{listen_proto} eq 'fcgi' && FU::fcgi::new(fileno $c->{client_sock}, $c->{proc});
@@ -423,11 +453,13 @@ sub _run_loop($c) {
passclient;
}
+ setstate 'working';
_do_req $c;
$c->{client_sock} = $c->{fcgi_obj} = undef if !($c->{fcgi_obj} && $c->{fcgi_obj}->keepalive);
- passclient if $c->{max_reqs} && !--$c->{max_reqs};
+ $count++;
+ passclient if $c->{max_reqs} && $count >= $c->{max_reqs};
}
}
@@ -466,34 +498,36 @@ sub _supervisor($c) {
if (!$err && (!$childs{$pid} || $childs{$pid} != 2)) {
$err = 1;
log_write "Script exited before calling FU::run()\n";
+ } elsif ($?) {
+ log_write "Unclean shutdown of worker PID $pid status $?\n";
}
delete $childs{$pid};
}
# Don't bother spawning more than 1 at a time while in error state
- my $spawn = !$err ? $c->{proc} - keys %childs : (grep $_ == 1, values %childs) ? 0 : 1;
+ my $spawn = !$err ? $c->{proc} - keys %childs : !@client_fd && (grep $_ == 1, values %childs) ? 0 : 1;
for (1..$spawn) {
- my $client = shift @client_fd;
+ my $client = @client_fd ? IO::Socket->new_from_fd(shift(@client_fd), 'r') : undef;
my $pid = fork;
die $! if !defined $pid;
if (!$pid) { # child
$SIG{CHLD} = $SIG{HUP} = $SIG{INT} = $SIG{TERM} = undef;
+ $0 = sprintf '%s: starting', $procname if $procname;
+ # In error state, wait with loading the script until we've received a request.
+ # Otherwise we'll end up in an infinite spawning loop if the script doesn't start properly.
+ $client = $c->{listen_sock}->accept() or die $! if !$client && $err;
if ($client) {
- $ENV{FU_CLIENT_FD} = $client;
- } elsif ($err) {
- # In error state, wait with loading the script until we've received a request.
- # Otherwise we'll end up in an infinite spawning loop if the script doesn't start properly.
- $client = $c->{listen_sock}->accept() or die $!;
fcntl $client, Fcntl::F_SETFD, 0;
$ENV{FU_CLIENT_FD} = fileno $client;
}
- exec $^X, (map "-I$_", @INC), $0;
+ exec $^X, (map "-I$_", @INC), $scriptpath;
exit 1;
}
- $client && IO::Socket->new_from_fd($client, 'r'); # close() the fd if we have one
$childs{$pid} = 1;
}
+ $0 = sprintf "%s: supervisor [%d/%d]", $procname, scalar keys %childs, $c->{proc} if $procname;
+
my ($fd, $msgadd) = FU::Util::fdpass_recv(fileno($rsock), 500);
push @client_fd, $fd if $fd;
next if !defined $msgadd;
@@ -621,8 +655,29 @@ sub db {
};
}
-sub sql { shift->db->q(@_) }
-sub SQL { shift->db->Q(@_) }
+sub sql { shift->db->sql(@_) }
+sub SQL { shift->db->SQL(@_) }
+
+sub _fmt_section($s) { $s =~ s/^\s*/ /r =~ s/\s+$//r =~ s/\n/\n /rg }
+
+sub log_verbose($,$msg) {
+ my $r = $FU::REQ;
+ return FU::Log::log_write($msg) if $r->{log_verbose}++;
+ FU::Log::log_write(join "\n",
+ 'IP: '.($r->{ip}||'-'),
+ 'Headers:', (map " $_: $r->{hdr}{$_}", sort keys $r->{hdr}->%*),
+ $r->{multipart} ? ('Body (multipart):', _fmt_section join "\n", map $_->describe, $r->{multipart}->@*) :
+ $r->{json} ? ('Body (JSON):', _fmt_section FU::Util::json_format($r->{json}, pretty => 1, canonical => 1)) :
+ $r->{formdata} ? ('Body (formdata):', _fmt_section FU::Util::json_format($r->{formdata}, pretty => 1, canonical => 1)) :
+ length $r->{body} ? do {
+ my $b = substr $r->{body}, 0, 4096;
+ my $trunc = length $r->{body} > 4096 ? ', truncated' : '';
+ utf8::decode($b) ? ("Body (utf8$trunc):", _fmt_section($b =~ s/\r//rg =~ s/\n{4,}/\n[..]\n/rg))
+ : ("Body (hex$trunc):", _fmt_section(unpack('H*', $b) =~ s/(.{128})/$1\n/rg))
+ } : (),
+ 'Message:', _fmt_section $msg
+ );
+}
@@ -636,12 +691,13 @@ sub headers { $FU::REQ->{hdr} }
sub ip { $FU::REQ->{ip} }
sub _getfield($data, @a) {
- return $data->{$a[0]} if @a == 1 && !ref $a[0];
- require FU::Validate;
+ if (@a == 1 && !ref $a[0]) {
+ fu->error(400, "Expected top-level to be a hash") if ref $data ne 'HASH';
+ return $data->{$a[0]};
+ }
my $schema = FU::Validate->compile(@a > 1 ? { keys => {@a} } : $a[0]);
my $res = $schema->validate($data);
- fu->error(400, "Input validation failed") if !$res; # TODO: More detailed error message
- return @a == 2 ? $res->data->{$a[0]} : $res->data;
+ return @a == 2 ? $res->{$a[0]} : $res;
}
sub query {
@@ -651,18 +707,50 @@ sub query {
_getfield $FU::REQ->{qs_parsed}, @_;
}
+sub cookie {
+ shift;
+ return fu->header('cookie') if !@_;
+ $FU::REQ->{cookie} ||= do {
+ my %c;
+ for my $c (split /; /, fu->header('cookie')||'') {
+ my($n, $v) = split /=/, $c, 2;
+ if (!defined $v) {}
+ elsif (!exists $c{$n}) { $c{$n} = $v }
+ elsif (ref $c{$n}) { push $c{$n}->@*, $v }
+ else { $c{$n} = [ $c{$n}, $v ] }
+ }
+ \%c
+ };
+ _getfield $FU::REQ->{cookie}, @_;
+}
+
+sub json {
+ shift;
+ fu->error(400, "Invalid content type for json") if (fu->header('content-type')||'') !~ m{^application/json(?:;\s*charset=utf-?8)?$}i;
+ return FU::Util::utf8_decode(my $x = $FU::REQ->{body}) if !@_;
+ $FU::REQ->{json} ||= eval {
+ FU::Util::json_parse($FU::REQ->{body}, utf8 => 1)
+ } || fu->error(400, "JSON parse error: $@");
+ _getfield $FU::REQ->{json}, @_;
+}
+
sub formdata {
shift;
+ fu->error(400, "Invalid content type for form data") if (fu->header('content-type')||'') ne 'application/x-www-form-urlencoded';
+ return FU::Util::utf8_decode(my $x = $FU::REQ->{body}) if !@_;
$FU::REQ->{formdata} ||= eval {
- # TODO: Support multipart encoding
- confess "Invalid content type for form data"
- if (fu->header('content-type')||'') ne 'application/x-www-form-urlencoded';
- FU::Util::query_decode($FU::REQ->{data});
+ FU::Util::query_decode($FU::REQ->{body});
} || fu->error(400, $@);
- # TODO: Accept schema validation thing.
_getfield $FU::REQ->{formdata}, @_;
}
+sub multipart {
+ require FU::MultipartFormData;
+ $FU::REQ->{multipart} ||= eval {
+ FU::MultipartFormData->parse(fu->header('content-type')||'', $FU::REQ->{body})
+ } || fu->error(400, $@);
+}
+
@@ -687,12 +775,13 @@ sub reset {
$FU::REQ->{reshdr} = {
'content-type', 'text/html',
};
+ delete $FU::REQ->{rescookie};
}
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]/;
+ confess "Invalid attempt to set response header containing a newline" if defined $val && $val =~ /[\r\n]/;
}
sub add_header($, $hdr, $val) {
@@ -709,6 +798,45 @@ sub set_header($, $hdr, $val=undef) {
$FU::REQ->{reshdr}{ lc $hdr } = $val;
}
+sub set_cookie($, $name, $val=undef, %opt) {
+ confess "Invalid cookie name '$name'" if $name !~ /^$FU::hdrname_re$/;
+ return delete $FU::REQ->{rescookie}{$name} if !defined $val;
+ confess "Invalid cookie value: $val" if $val =~ /[\0-\x1f\x7f-\x{10ffff}\s\r\n\t",;\\]/;
+ my $c = "$name=$val";
+ for my ($k,$v) (%opt) {
+ $k = lc $k; # attributes are case-insensitive
+ if ($k eq 'domain') {
+ confess "Invalid cookie domain: $v" if $v !~ $FU::Validate::re_domain;
+ } elsif ($k eq 'expires') {
+ confess "Cookie 'Expires' attribute should be a UNIX timestamp" if defined $v && $v !~ /^[0-9]+$/;
+ $v = FU::Util::httpdate_format($v || 0);
+ } elsif ($k eq 'httponly') {
+ $c .= "; $k" if $v;
+ next;
+ } elsif ($k eq 'max-age') {
+ confess "Invalid 'Max-Age' cookie attribute: $v" if $v !~ /^[0-9]+$/;
+ } elsif ($k eq 'partitioned') {
+ $c .= "; $k" if $v;
+ next;
+ } elsif ($k eq 'path') {
+ confess "Invalid 'Path' cookie attribute: $v" if $v =~ /[\0-\x1f\x7f-\x{10ffff}\s\r\n\t",;\\]/;
+ } elsif ($k eq 'secure') {
+ $c .= "; $k" if $v;
+ next;
+ } elsif ($k eq 'samesite') {
+ confess "Invalid 'SameSite' cookie attribute: $v" if $v !~ /^(?:Strict|Lax|None)$/;
+ }
+ $c .= "; $k=$v";
+ }
+ $FU::REQ->{rescookie}{$name} = $c;
+}
+
+sub send_json($, $data) {
+ fu->set_header('content-type', 'application/json');
+ fu->set_body(FU::Util::json_format($data, canonical => 1, utf8 => 1));
+ fu->done;
+}
+
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
@@ -742,7 +870,6 @@ sub send_file($, $root, $path) {
sub redirect($, $code, $location) {
state $alias = {qw/ perm 301 temp 302 tempget 303 tempsame 307 permsame 308 /};
- fu->reset;
fu->status($alias->{$code} // $code);
fu->set_header(location => "$location");
fu->set_header('content-type', 'text/plain');
@@ -775,9 +902,12 @@ sub _error_page($, $code, $title, $msg) {
}
sub _finalize {
- state $haszlib = eval { require Compress::Raw::Zlib; 1 };
+ state $hasgzip = FU::Util::gzip_lib();
+ state $hasbrotli = eval { FU::Util::brotli_compress(6, ''); 1 };
my $r = $FU::REQ;
+ fu->add_header('set-cookie', $_) for $r->{rescookie} ? sort values $r->{rescookie}->%* : ();
+
if ($r->{status} == 204 || $r->{status} == 304) {
delete $r->{reshdr}{'content-length'};
delete $r->{reshdr}{'content-encoding'};
@@ -785,21 +915,24 @@ sub _finalize {
$r->{resbody} = '';
} else {
- if ($haszlib && length($r->{resbody}) > 256
- && !defined $r->{reshdr}{'content-encoding'} && FU::compress_mimes->{$r->{reshdr}{'content-type'}}) {
+ my @vary = ref $r->{reshdr}{vary} eq 'ARRAY' ? $r->{reshdr}{vary}->@* : defined $r->{reshdr}{vary} ? ($r->{reshdr}{vary}) : ();
+ if (($hasgzip || $hasbrotli) && length($r->{resbody}) > 256
+ && !defined $r->{reshdr}{'content-encoding'}
+ && FU::compress_mimes->{$r->{reshdr}{'content-type'}}
+ ) {
+ push @vary, 'accept-encoding';
+ if ($hasbrotli && ($r->{hdr}{'accept-encoding'}||'') =~ /\bbr\b/) {
+ $r->{resbody_orig} = $r->{resbody};
+ $r->{resbody} = FU::Util::brotli_compress(6, $r->{resbody});
+ $r->{reshdr}{'content-encoding'} = 'br';
- $r->{reshdr}{'vary'} = ($r->{reshdr}{'vary'} ? $r->{reshdr}{'vary'}.', ' : '').'accept-encoding'
- if ($r->{reshdr}{'vary'}||'') !~ /accept-encoding/i;
-
- if ($haszlib && ($r->{hdr}{'accept-encoding'}||'') =~ /gzip/) {
- # Use lower-level API because the higher-level Compress::Zlib loads a whole bunch of other modules.
- my $z = Compress::Raw::Zlib::Deflate->new(-WindowBits => Compress::Raw::Zlib::WANT_GZIP(), -Level => 3, -AppendOutput => 1);
- $z->deflate($r->{resbody}, my $buf);
- $z->flush($buf);
- $r->{resbody} = $buf;
+ } elsif ($hasgzip && ($r->{hdr}{'accept-encoding'}||'') =~ /\bgzip\b/) {
+ $r->{resbody_orig} = $r->{resbody};
+ $r->{resbody} = FU::Util::gzip_compress(6, $r->{resbody});
$r->{reshdr}{'content-encoding'} = 'gzip';
}
}
+ $r->{reshdr}{vary} = @vary ? join ', ', @vary : undef;
$r->{reshdr}{'content-length'} = length $r->{resbody};
$r->{resbody} = '' if (fu->method//'') eq 'HEAD';
}
@@ -849,15 +982,7 @@ __END__
=head1 NAME
-FU - Framework Ultimatum: A Lean and Efficient Zero-Dependency Web Framework.
-
-=head1 EXPERIMENTAL
-
-This module is still in development: it's missing important functionality and
-there will likely be a few breaking API changes. This framework currently
-powers manned.org as a test. I'll do a stable 1.0 release once FU is used in
-production for vndb.org, which will take a few months in the best case
-scenario.
+FU - A Lean and Efficient Zero-Dependency Web Framework.
=head1 SYNOPSIS
@@ -875,7 +1000,7 @@ scenario.
}
FU::get qr{/hello/(.+)}, sub($who) {
- my_html_ "Website title", sub {
+ myhtml_ "Website title", sub {
h1_ "Hello, $who!";
};
};
@@ -884,6 +1009,11 @@ scenario.
=head1 DESCRIPTION
+FU is the backend web framework developed for L and
+L, but is also perfectly suitable for other
+projects. Besides a web framework, this distrubion also includes a bunch of
+handy utility functions and modules.
+
=head2 Distribution Overview
This top-level C module is a web development framework. The C
@@ -916,6 +1046,12 @@ is). There are a few additional optional dependencies:
=item * C - required for L, dynamically loaded through
C.
+=item * C or C or C - required for
+C in L and used for HTTP output compression.
+
+=item * C - required for C in L
+and used for HTTP output compression.
+
=back
@@ -954,16 +1090,22 @@ certainly not great if you plan to transfer large files.
=back
The rest of this document is reference documentation; there's no easy
-introductionary cookbook-style docs yet, sorry about that.
+introductory cookbook-style docs yet, sorry about that.
Unless specifically mentioned otherwise, all methods and functions taking or
returning strings deal with perl Unicode strings, not raw bytes.
-=head2 Framework Configuration
+=head1 Framework Configuration
=over
+=item use FU -procname => $name
+
+When the C<-procname> import option is set, FU automatically updates the
+process name (as displayed in L and L, see C<$0>) with
+information about the current process, prefixed with the given C<$name>.
+
=item FU::init_db($info)
Set database configuration. C<$info> can either be a connection string for C<<
@@ -1057,7 +1199,7 @@ restart loop.
=back
-=head2 Handlers & Routing
+=head1 Handlers & Routing
=over
@@ -1124,12 +1266,18 @@ for a certain error code, C<500> is used as fallback.
=back
+All of the above C<$sub> callbacks are allowed to throw an error. Special
+handling is given to exceptions generated by C<< fu->error() >>, which are
+relegated to the appropriate C handler, and errors thrown by the
+C method of L, which result in the C<400> error
+handler being run. Any other exception is passed to the C<500> error handler.
-=head2 The 'fu' Object
+
+=head1 The 'fu' Object
While the C namespace is used for global configuration and utility
functions, the C object is intended for methods that deal with request
-processing (although some are useful used outside of request handlers as well).
+processing (although some are useful outside of request handlers as well).
The C object itself can be used to store request-local data. For example,
the following is a valid approach to handle user authentication:
@@ -1167,15 +1315,23 @@ has successfully been processed, or rolled back if there was an error.
=item fu->sql($query, @params)
-Convenient short-hand for C<< fu->db->q($query, @params) >>.
+Convenient short-hand for C<< fu->db->sql($query, @params) >>.
=item fu->SQL(@args)
-Convenient short-hand for C<< fu->db->Q(@args) >>.
+Convenient short-hand for C<< fu->db->SQL(@args) >>.
+
+=item fu->log_verbose($message)
+
+Write a verbose multi-line message to the log, including a full dump of
+information about the request: IP, headers and (potentially reformatted and/or
+truncated) body. This extra info is only written once per request, further
+calls to C just go directly to L's C
+instead.
=back
-=head2 Request Information
+=head1 Request Information
=over
@@ -1211,15 +1367,24 @@ C this returns C.
=item fu->query($name)
Parses the raw query string with C in L and returns the
-value with the given $name. Beware: multiple values are returned as an array.
-Prefer to use the C<$schema>-based validation methods below to reliably handle
-all sorts of query strings.
+value with the given $name. Beware: an array is returned if the given key is
+repeated in the query string. Prefer to use the C<$schema>-based validation
+methods below to reliably handle all sorts of query strings.
=item fu->query($name => $schema)
Parse, validate and return the query parameter identified by C<$name> with the
-given L schema. Calls C<< fu->error(400) >> with a useful error
-message if validation fails.
+given L schema.
+
+To fetch a query parameter that may have multiple values, use:
+
+ my $arrayref = fu->query(q => {accept_scalar => 1});
+
+ # OR:
+ my $first_value = fu->query(q => {accept_array => 'first'});
+
+ # OR:
+ my $last_value = fu->query(q => {accept_array => 'last'});
=item fu->query($schema)
@@ -1236,22 +1401,40 @@ Parse, validate and return multiple query parameters.
# Or, more concisely:
my $data = fu->query(a => {anybool => 1}, b => {});
-=item fu->formdata($name)
+To fetch all query paramaters as decoded by C, use:
-=item fu->formdata($schema)
+ my $data = fu->query({type=>'any'});
-Like C<< fu->query() >> but returns data from the POST request body.
+=item fu->cookie(...)
+
+Like C<< fu->query() >> but parses the C request header. Beware that,
+exactly like with query parameters, it's possible for a cookie to have multiple
+values and thus get represented as an array.
+
+=item fu->json(...)
+
+Like C<< fu->query() >> but parses the request body as JSON. Returns the raw
+(unvalidated!) JSON Unicode string if no arguments are given. To retrieve the
+decoded JSON data without performing further validation, use:
+
+ my $data = fu->json({type=>'any'});
+
+=item fu->formdata(...)
+
+Like C<< fu->query() >> but returns data from the POST request body. This
+method only supports form data encoded as C,
+which is the default for HTML C<<