package CGI::Pure;
use strict;
use warnings;
use CGI::Deurl::XS qw(parse_query_string);
use Class::Utils qw(set_params);
use Encode qw(decode_utf8);
use English qw(-no_match_vars);
use Error::Pure qw(err);
use List::MoreUtils qw(none);
use Readonly;
use URI::Escape qw(uri_escape uri_escape_utf8 uri_unescape);
# Constants.
Readonly::Scalar my $EMPTY_STR => q{};
Readonly::Scalar my $POST_MAX => 102_400;
Readonly::Scalar my $POST_MAX_NO_LIMIT => -1;
Readonly::Scalar my $BLOCK_SIZE => 4_096;
Readonly::Array my @PAR_SEP => (q{&}, q{;});
our $VERSION = 0.09;
# Constructor.
sub new {
my ($class, @params) = @_;
# Create object.
my $self = bless {}, $class;
# CRLF separator.
$self->{'crlf'} = undef;
# Disable upload.
$self->{'disable_upload'} = 1;
# Init.
$self->{'init'} = undef;
# Parameter separator.
$self->{'par_sep'} = q{&};
# Use a post max of 100K ($POST_MAX),
# set to -1 ($POST_MAX_NO_LIMIT) for no limits.
$self->{'post_max'} = $POST_MAX;
# Save query data from server.
$self->{'save_query_data'} = 0;
# UTF8 CGI params.
$self->{'utf8'} = 1;
# Process params.
set_params($self, @params);
# Check to parameter separator.
if (none { $_ eq $self->{'par_sep'} } @PAR_SEP) {
err "Bad parameter separator '$self->{'par_sep'}'.";
}
# Global object variables.
$self->_global_variables;
# Initialization.
my $init = $self->{'init'};
delete $self->{'init'};
$self->_initialize($init);
# Object.
return $self;
}
# Append param value.
sub append_param {
my ($self, $param, @values) = @_;
# Clean from undefined values.
my @new_values = _remove_undef(@values);
# Process scalars, arrays, err on other.
my @values_to_add;
foreach my $value (@new_values) {
if (ref $value eq 'ARRAY') {
push @values_to_add, @{$value};
} elsif (ref $value eq '') {
push @values_to_add, $value;
} else {
err "Parameter '$param' has bad value.";
}
}
$self->_add_param($param, [@values_to_add]);
return $self->param($param);
}
# Clone class to my class.
sub clone {
my ($self, $class) = @_;
foreach my $param ($class->param) {
$self->param($param, $class->param($param));
}
return;
}
# Delete param.
sub delete_param {
my ($self, $param) = @_;
if (! defined $self->{'.parameters'}->{$param}) {
return;
}
delete $self->{'.parameters'}->{$param};
return 1;
}
# Delete all params.
sub delete_all_params {
my $self = shift;
delete $self->{'.parameters'};
$self->{'.parameters'} = {};
return;
}
# Return param[s]. If sets parameters, than overwrite.
sub param {
my ($self, $param, @values) = @_;
# Return list of all params.
if (! defined $param) {
return sort keys %{$self->{'.parameters'}};
}
# Clean from undefined values.
my @new_values = _remove_undef(@values);
# Return values for $param.
if (! @new_values) {
if (! exists $self->{'.parameters'}->{$param}) {
return ();
}
# Values exists, than sets them.
} else {
$self->_add_param($param, (ref $new_values[0] eq 'ARRAY'
? $new_values[0] : [@new_values]), 'overwrite');
}
# Return values of param, or first value of param.
return wantarray ? sort @{$self->{'.parameters'}->{$param}}
: $self->{'.parameters'}->{$param}->[0];
}
# Gets query data from server.
sub query_data {
my $self = shift;
if ($self->{'save_query_data'}) {
return $self->{'.query_data'};
} else {
return 'Not saved query data.';
}
}
# Return actual query string.
sub query_string {
my $self = shift;
my @pairs;
foreach my $param ($self->param) {
foreach my $value ($self->param($param)) {
push @pairs, $self->_uri_escape($param).q{=}.
$self->_uri_escape($value);
}
}
return join $self->{'par_sep'}, @pairs;
}
# Upload file from tmp.
sub upload {
my ($self, $filename, $writefile) = @_;
if ($ENV{'CONTENT_TYPE'} !~ m/^multipart\/form-data/ismx) {
err 'File uploads only work if you specify '.
'enctype="multipart/form-data" in your form.';
}
if (! $filename) {;
if ($writefile) {
err 'No filename submitted for upload to '.
"'$writefile'.";
}
return $self->{'.filehandles'}
? keys %{$self->{'.filehandles'}} : ();
}
my $fh = $self->{'.filehandles'}->{$filename};
if ($fh) {
# Get ready for reading.
seek $fh, 0, 0;
if (! $writefile) {
return $fh;
}
binmode $fh;
my $buffer;
my $out;
if (! open $out, '>', $writefile) {
err "Cannot write file '$writefile': $!.";
}
binmode $out;
while (read $fh, $buffer, $BLOCK_SIZE) {
print {$out} $buffer;
}
if (! close $out) {
err "Cannot close file '$writefile': $!.";
}
$self->{'.filehandles'}->{$filename} = undef;
undef $fh;
} else {
err "No filehandle for '$filename'. ".
'Are uploads enabled (disable_upload = 0)? '.
'Is post_max big enough?';
}
return;
}
# Return informations from uploaded files.
sub upload_info {
my ($self, $filename, $info) = @_;
if ($ENV{'CONTENT_TYPE'} !~ m/^multipart\/form-data/ismx) {
err 'File uploads only work if you '.
'specify enctype="multipart/form-data" in your '.
'form.';
}
if (! $filename) {
return keys %{$self->{'.tmpfiles'}};
}
if ($info =~ m/mime/ims) {
return $self->{'.tmpfiles'}->{$filename}->{'mime'}
}
return $self->{'.tmpfiles'}->{$filename}->{'size'};
}
# Adding param.
sub _add_param {
my ($self, $param, $value, $overwrite) = @_;
if (! defined $param) {
return ();
}
if ($overwrite
|| ! exists $self->{'.parameters'}->{$param}) {
$self->{'.parameters'}->{$param} = [];
}
my @values = ref $value eq 'ARRAY' ? @{$value} : ($value);
foreach my $value (@values) {
push @{$self->{'.parameters'}->{$param}}, $value;
}
return;
}
# Common parsing from any methods..
sub _common_parse {
my $self = shift;
my $data;
# Information from server.
my $type = $ENV{'CONTENT_TYPE'} || 'No CONTENT_TYPE received';
my $length = $ENV{'CONTENT_LENGTH'} || 0;
my $method = $ENV{'REQUEST_METHOD'} || 'No REQUEST_METHOD received';
# Multipart form data.
if ($length && $type =~ m/^multipart\/form-data/imsx) {
# Get data_length, store data to internal structure.
my $got_data_length = $self->_parse_multipart;
# Bad data length vs content_length.
if ($length != $got_data_length) {
err "500 Bad read! wanted $length, got ".
"$got_data_length.";
}
return;
# POST method.
} elsif ($method eq 'POST') {
# Maximal post length is above my length.
if ($self->{'post_max'} != $POST_MAX_NO_LIMIT
and $length > $self->{'post_max'}) {
err '413 Request entity too large: '.
"$length bytes on STDIN exceeds ".
'post_max !';
# Get data.
} elsif ($length) {
read STDIN, $data, $length;
}
# Save data for post.
if ($self->{'save_query_data'}) {
$self->{'.query_data'} = $data;
}
# Bad length of data.
if ($length != length $data) {
err "500 Bad read! wanted $length, got ".
(length $data).q{.};
}
# GET/HEAD method.
} elsif ($method eq 'GET' || $method eq 'HEAD') {
$data = $ENV{'QUERY_STRING'} || $EMPTY_STR;
if ($self->{'save_query_data'}) {
$self->{'.query_data'} .= $data;
}
}
# Parse params.
if ($data) {
$self->_parse_params($data);
}
return;
}
# Define the CRLF sequence.
sub _crlf {
my $self = shift;
# If not defined.
if (! defined $self->{'crlf'}) {
# VMS.
if ($OSNAME =~ m/VMS/ims) {
$self->{'crlf'} = "\n";
# EBCDIC systems.
} elsif ("\t" eq "\011") {
$self->{'crlf'} = "\015\012";
# Other.
} else {
$self->{'crlf'} = "\r\n";
}
}
# Return sequence.
return $self->{'crlf'};
}
# Sets global object variables.
sub _global_variables {
my $self = shift;
$self->{'.parameters'} = {};
$self->{'.query_data'} = $EMPTY_STR;
return;
}
# Initializating CGI::Pure with something input methods.
sub _initialize {
my ($self, $init) = @_;
# Initialize from QUERY_STRING, STDIN or @ARGV.
if (! defined $init) {
$self->_common_parse;
# Initialize from param hash.
} elsif (ref $init eq 'HASH') {
foreach my $param (keys %{$init}) {
$self->_add_param($param, $init->{$param});
}
# Inicialize from CGI::Pure object.
# XXX Mod_perl?
} elsif (eval { $init->isa('CGI::Pure') }) {
$self->clone($init);
# Initialize from a query string.
} else {
$self->_parse_params($init);
}
return;
}
# Parse multipart data.
sub _parse_multipart {
my $self = shift;
my ($boundary) = $ENV{'CONTENT_TYPE'}
=~ /
boundary=
\"?([^\";,]+)\"?
/msx;
if (! $boundary) {
err '400 No boundary supplied for multipart/form-data.';
}
# BUG: IE 3.01 on the Macintosh uses just the boundary, forgetting
# the --
if (! exists $ENV{'HTTP_USER_AGENT'} || $ENV{'HTTP_USER_AGENT'} !~ m/
MSIE\s+
3\.0[12];
\s*
Mac
/imsx) {
$boundary = q{--}.$boundary;
}
$boundary = quotemeta $boundary;
my $got_data_length = 0;
my $data = $EMPTY_STR;
my $read;
my $CRLF = $self->_crlf;
READ:
while (read STDIN, $read, $BLOCK_SIZE) {
# Adding post data.
if ($self->{'save_query_data'}) {
$self->{'.query_data'} .= $read;
}
$data .= $read;
$got_data_length += length $read;
BOUNDARY:
while ($data =~ m/^$boundary$CRLF/ms) {
my $header;
# Get header, delimited by first two CRLFs we see.
if ($data !~ m/^([\040-\176$CRLF]+?$CRLF$CRLF)/ms) {
next READ;
}
# XXX Proc tohle nemuze byt? /x tam dela nejake potize.
# if ($data !~ m/^(
# [\040-\176$CRLF]+?
# $CRLF
# $CRLF
# )/msx) {
#
# next READ;
# }
$header = $1;
# Unhold header per RFC822.
(my $unfold = $1) =~ s/$CRLF\s+/\ /gms;
my ($param) = $unfold =~ m/
form-data;
\s+
name="?([^\";]*)"?
/msx;
my ($filename) = $unfold =~ m/
name="?\Q$param\E"?;
\s+
filename="?([^\"]*)"?
/msx;
if ($filename) {
my ($mime) = $unfold =~ m/
Content-Type:
\s+
([-\w\/]+)
/imsx;
# Trim off header.
$data =~ s/^\Q$header\E//ms;
($got_data_length, $data, my $fh, my $size)
= $self->_save_tmpfile($boundary,
$filename, $got_data_length, $data);
$self->_add_param($param, $filename);
# Filehandle.
if ($fh) {
$self->{'.filehandles'}->{$filename}
= $fh;
}
# Information about file.
if ($size) {
$self->{'.tmpfiles'}->{$filename} = {
'size' => $size,
'mime' => $mime,
};
}
next BOUNDARY;
}
if ($data !~ s/^\Q$header\E(.*?)$CRLF(?=$boundary)//s) {
next READ;
}
# XXX /x
# if ($data !~ s/^
# \Q$header\E
# (.*?)
# $CRLF
# (?=$boundary)
# //msx) {
#
# next READ;
# }
my $param_value;
if ($self->{'utf8'}) {
$param_value = decode_utf8($1);
} else {
$param_value = $1;
}
$self->_add_param($param, $param_value);
}
}
# Length of data.
return $got_data_length;
}
# Parse params from data.
sub _parse_params {
my ($self, $data) = @_;
if (! defined $data) {
return ();
}
# Parse params.
my $pairs_hr = parse_query_string($data);
foreach my $key (keys %{$pairs_hr}) {
# Value processing.
my $value;
if ($self->{'utf8'}) {
if (ref $pairs_hr->{$key} eq 'ARRAY') {
my @decoded = ();
foreach my $val (@{$pairs_hr->{$key}}) {
push @decoded, decode_utf8($val);
}
$value = \@decoded;
} else {
$value = decode_utf8($pairs_hr->{$key});
}
} else {
$value = $pairs_hr->{$key};
}
# Add parameter.
$self->_add_param($key, $value);
}
return;
}
# Remove undefined values.
sub _remove_undef {
my (@values) = @_;
my @new_values = grep { defined $_ } @values;
return @new_values;
}
# Save file from multiform.
sub _save_tmpfile {
my ($self, $boundary, $filename, $got_data_length, $data) = @_;
my $fh;
my $CRLF = $self->_crlf;
my $file_size = 0;
if ($self->{'disable_upload'}) {
err '405 Not Allowed - File uploads are disabled.';
} elsif ($filename) {
eval {
require IO::File;
};
if ($EVAL_ERROR) {
err "500 IO::File is not available $EVAL_ERROR.";
}
$fh = new_tmpfile IO::File;
if (! $fh) {
err '500 IO::File can\'t create new temp_file.';
}
}
binmode $fh;
while (1) {
my $buffer = $data;
read STDIN, $data, $BLOCK_SIZE;
if (! $data) {
$data = $EMPTY_STR;
}
$got_data_length += length $data;
if ("$buffer$data" =~ m/$boundary/ms) {
$data = $buffer.$data;
last;
}
# BUG: Fixed hanging bug if browser terminates upload part way.
if (! $data) {
undef $fh;
err '400 Malformed multipart, no terminating '.
'boundary.';
}
# We do not have partial boundary so print to file if valid $fh.
print {$fh} $buffer;
$file_size += length $buffer;
}
$data =~ s/^
(.*?)
$CRLF
(?=$boundary)
//smx;
# Print remainder of file if value $fh.
if ($1) {
print {$fh} $1;
$file_size += length $1;
}
return $got_data_length, $data, $fh, $file_size;
}
# Escapes uri.
sub _uri_escape {
my ($self, $string) = @_;
if ($self->{'utf8'}) {
$string = uri_escape_utf8($string);
} else {
$string = uri_escape($string);
}
$string =~ s/\ /\+/gsm;
return $string;
}
# Unescapes uri.
sub _uri_unescape {
my ($self, $string) = @_;
$string =~ s/\+/\ /gsm;
return uri_unescape($string);
}
1;
__END__
=pod
=encoding utf8
=head1 NAME
CGI::Pure - Common Gateway Interface Class.
=head1 SYNOPSIS
use CGI::Pure;
my $cgi = CGI::Pure->new(%parameters);
$cgi->append_param('par', 'value');
my @par_value = $cgi->param('par');
$cgi->delete_param('par');
$cgi->delete_all_params;
my $query_string = $cgi->query_string;
$cgi->upload('filename', '~/filename');
my $mime = $cgi->upload_info('filename', 'mime');
my $query_data = $cgi->query_data;
=head1 METHODS
=over 8
=item C<new(%parameters)>
Constructor
=over 8
=item * C<disable_upload>
Disables file upload.
Default value is 1.
=item * C<init>
Initialization variable.
May be:
- CGI::Pure object.
- Hash with params.
- Query string.
Default is undef.
=item * C<par_sep>
Parameter separator.
Default value is '&'.
Possible values are '&' or ';'.
=item * C<post_max>
Maximal post length.
-1 means no limit.
Default value is 102400kB
=item * C<save_query_data>
Flag, that means saving query data.
When is enable, is possible use query_data method.
Default value is 0.
=item * C<utf8>
Flag, that means utf8 CGI parameters handling.
Default is 1.
=back
=item C<append_param($param, [@values])>
Append param value.
Returns all values for param.
=item C<clone($class)>
Clone class to my class.
=item C<delete_param($param)>
Delete param.
Returns undef, when param doesn't exist.
Returns 1, when param was deleted.
=item C<delete_all_params()>
Delete all params.
=item C<param([$param], [@values])>
Returns or sets parameters in CGI.
params() returns all parameters name.
params('param') returns parameter 'param' value.
params('param', 'val1', 'val2') sets parameter 'param' to 'val1' and 'val2'
values.
=item C<query_data()>
Gets query data from server.
There is possible only for enabled 'save_data' flag.
=item C<query_string()>
Returns actual query string.
=item C<upload($filename, [$write_to])>
Upload file from tmp.
upload() returns array of uploaded filenames.
upload($filename) returns handler to uploaded filename.
upload($filename, $write_to) uploads temporary '$filename' file to
'$write_to' file.
=item C<upload_info($filename, [$info])>
Returns informations from uploaded files.
upload_info() returns array of uploaded files.
upload_info('filename') returns size of uploaded 'filename' file.
upload_info('filename', 'mime') returns mime type of uploaded 'filename' file.
=back
=head1 ERRORS
new():
400 Malformed multipart, no terminating boundary.
400 No boundary supplied for multipart/form-data.
405 Not Allowed - File uploads are disabled.
413 Request entity too large: %s bytes on STDIN exceeds post_max !
500 Bad read! wanted %s, got %s.
500 IO::File can\'t create new temp_file.
500 IO::File is not available %s.
Bad parameter separator '%s'.
From Class::Utils::set_params():
Unknown parameter '%s'.
append_param():
Parameter '%s' has bad value.
upload():
Cannot close file '%s': %s.
Cannot write file '%s': %s.
File uploads only work if you specify enctype="multipart/form-data" in your form.
No filehandle for '%s'. Are uploads enabled (disable_upload = 0)? Is post_max big enough?
No filename submitted for upload to '$writefile'.
upload_info():
File uploads only work if you specify enctype="multipart/form-data" in your form.
=head1 EXAMPLE1
use strict;
use warnings;
use CGI::Pure;
# Object.
my $query_string = 'par1=val1;par1=val2;par2=value';
my $cgi = CGI::Pure->new(
'init' => $query_string,
);
foreach my $param_key ($cgi->param) {
print "Param '$param_key': ".join(' ', $cgi->param($param_key))."\n";
}
# Output:
# Param 'par1': val1 val2
# Param 'par2': value
=head1 EXAMPLE2
use strict;
use warnings;
use CGI::Pure;
# Object.
my $cgi = CGI::Pure->new;
$cgi->param('par1', 'val1', 'val2');
$cgi->param('par2', 'val3');
$cgi->append_param('par2', 'val4');
foreach my $param_key ($cgi->param) {
print "Param '$param_key': ".join(' ', $cgi->param($param_key))."\n";
}
# Output:
# Param 'par2': val3 val4
# Param 'par1': val1 val2
=head1 DEPENDENCIES
L<Class::Utils>,
L<CGI::Deurl::XS>,
L<Error::Pure>,
L<URI::Escape>.
=head1 SEE ALSO
=over
=item L<CGI::Pure::Fast>
Fast Common Gateway Interface Class for CGI::Pure.
=item L<CGI::Pure::Save>
Common Gateway Interface Class for loading/saving object in file.
=back
=head1 AUTHOR
Michal Josef Špaček L<mailto:skim@cpan.org>
L<http://skim.cz>
=head1 LICENSE AND COPYRIGHT
© 2004-2021 Michal Josef Špaček
BSD 2-Clause License
=head1 VERSION
0.09
=cut