]> mj.ucw.cz Git - libucw.git/blob - ucw/perl/UCW/CGI.pm
UCW::CGI error handling split off to separate module UCW::CGI::ErrorHandler
[libucw.git] / ucw / perl / UCW / CGI.pm
1 #       Poor Man's CGI Module for Perl
2 #
3 #       (c) 2002--2011 Martin Mares <mj@ucw.cz>
4 #       Slightly modified by Tomas Valla <tom@ucw.cz>
5 #
6 #       This software may be freely distributed and used according to the terms
7 #       of the GNU Lesser General Public License.
8
9 package UCW::CGI;
10
11 use strict;
12 use warnings;
13
14 require Exporter;
15 our $VERSION = 1.0;
16 our @ISA = qw(Exporter);
17 our @EXPORT = qw(&html_escape &url_escape &url_deescape &url_param_escape &url_param_deescape &self_ref &self_form &http_get);
18 our @EXPORT_OK = qw();
19
20 # Configuration settings
21 our $debug = 0;
22 our $utf8_mode = 0;
23
24 sub http_error($;@) {
25         my $err = shift @_;
26         print join("\n", "Status: $err", "Content-Type: text/plain", @_, "", $err, "");
27         exit;
28 }
29
30 ### Escaping ###
31
32 sub url_escape($) {
33         my $x = shift @_;
34         utf8::encode($x) if $utf8_mode;
35         $x =~ s/([^-\$_.!*'(),0-9A-Za-z\x80-\xff])/"%".unpack('H2',$1)/ge;
36         utf8::decode($x) if $utf8_mode;
37         return $x;
38 }
39
40 sub url_deescape($) {
41         my $x = shift @_;
42         utf8::encode($x) if $utf8_mode;
43         $x =~ s/%(..)/pack("H2",$1)/ge;
44         utf8::decode($x) if $utf8_mode;
45         return $x;
46 }
47
48 sub url_param_escape($) {
49         my $x = shift @_;
50         $x = url_escape($x);
51         $x =~ s/%20/+/g;
52         return $x;
53 }
54
55 sub url_param_deescape($) {
56         my $x = shift @_;
57         $x =~ s/\+/ /g;
58         return url_deescape($x);
59 }
60
61 sub html_escape($) {
62         my $x = shift @_;
63         $x =~ s/&/&amp;/g;
64         $x =~ s/</&lt;/g;
65         $x =~ s/>/&gt;/g;
66         $x =~ s/"/&quot;/g;
67         $x =~ s/'/&#39;/g;
68         return $x;
69 }
70
71 ### Analysing RFC 822 Style Headers ###
72
73 sub rfc822_prepare($) {
74         my $x = shift @_;
75         # Convert all %'s and backslash escapes to %xx escapes
76         $x =~ s/%/%25/g;
77         $x =~ s/\\(.)/"%".unpack("H2",$1)/ge;
78         # Remove all comments, beware, they can be nested (unterminated comments are closed at EOL automatically)
79         while ($x =~ s/^(("[^"]*"|[^"(])*(\([^)]*)*)(\([^()]*(\)|$))/$1 /) { }
80         # Remove quotes and escape dangerous characters inside (again closing at the end automatically)
81         $x =~ s{"([^"]*)("|$)}{my $z=$1; $z =~ s/([^0-9a-zA-Z%_-])/"%".unpack("H2",$1)/ge; $z;}ge;
82         # All control characters are properly escaped, tokens are clearly visible.
83         # Finally remove all unnecessary spaces.
84         $x =~ s/\s+/ /g;
85         $x =~ s/(^ | $)//g;
86         $x =~ s{\s*([()<>@,;:\\"/\[\]?=])\s*}{$1}g;
87         return $x;
88 }
89
90 sub rfc822_deescape($) {
91         my $x = shift @_;
92         return url_deescape($x);
93 }
94
95 ### Reading of HTTP headers ###
96
97 sub http_get($) {
98         my $h = shift @_;
99         $h =~ tr/a-z-/A-Z_/;
100         return $ENV{"HTTP_$h"} // $ENV{"$h"};
101 }
102
103 ### Parsing of Arguments ###
104
105 my $main_arg_table;
106 my %raw_args;
107
108 sub parse_raw_args_ll($$) {
109         my ($arg, $s) = @_;
110         $s =~ s/\r\n/\n/g;
111         $s =~ s/\r/\n/g;
112         utf8::decode($s) if $utf8_mode;
113         push @{$raw_args{$arg}}, $s;
114 }
115
116 sub parse_raw_args($) {
117         my ($s) = @_;
118         $s =~ s/\s+//;
119         for $_ (split /[&:]/, $s) {
120                 (/^([^=]+)=(.*)$/) or next;
121                 my $arg = $1;
122                 $_ = $2;
123                 s/\+/ /g;
124                 s/%(..)/pack("H2",$1)/eg;
125                 parse_raw_args_ll($arg, $_);
126         }
127 }
128
129 sub parse_multipart_form_data();
130
131 sub init_args() {
132         if (!defined $ENV{"GATEWAY_INTERFACE"}) {
133                 print STDERR "Must be called as a CGI script.\n";
134                 $UCW::CGI::ErrorHandler::exit_code = 1;
135                 exit;
136         }
137
138         my $method = $ENV{"REQUEST_METHOD"};
139         if (my $qs = $ENV{"QUERY_STRING"}) {
140                 parse_raw_args($qs);
141         }
142         if ($method eq "GET" || $method eq "HEAD") {
143         } elsif ($method eq "POST") {
144                 my $content_type = $ENV{"CONTENT_TYPE"} // "";
145                 if ($content_type =~ /^application\/x-www-form-urlencoded\b/i) {
146                         while (<STDIN>) {
147                                 chomp;
148                                 parse_raw_args($_);
149                         }
150                 } elsif ($content_type =~ /^multipart\/form-data\b/i) {
151                         parse_multipart_form_data();
152                 } else {
153                         http_error "415 Unsupported Media Type";
154                         exit;
155                 }
156         } else {
157                 http_error "405 Method Not Allowed", "Allow: GET, HEAD, POST";
158         }
159 }
160
161 sub parse_args($) {                     # CAVEAT: attached files must be defined in the main arg table
162         my $args = shift @_;
163         if (!$main_arg_table) {
164                 $main_arg_table = $args;
165                 init_args();
166         }
167
168         for my $a (values %$args) {
169                 my $r = ref($a->{'var'});
170                 $a->{'default'} //= '';
171                 if ($r eq 'SCALAR') {
172                         ${$a->{'var'}} = $a->{'default'};
173                 } elsif ($r eq 'ARRAY') {
174                         @{$a->{'var'}} = ();
175                 }
176         }
177
178         for my $arg (keys %$args) {
179                 my $a = $args->{$arg};
180                 defined($raw_args{$arg}) or next;
181                 for (@{$raw_args{$arg}}) {
182                         $a->{'multiline'} or s/(\n|\t)/ /g;
183                         s/^\s+//;
184                         s/\s+$//;
185                         if (my $rx = $a->{'check'}) {
186                                 if (!/^$rx$/) { $_ = $a->{'default'}; }
187                         }
188
189                         my $v = $a->{'var'};
190                         my $r = ref($v);
191                         if ($r eq 'SCALAR') {
192                                 $$v = $_;
193                         } elsif ($r eq 'ARRAY') {
194                                 push @$v, $_;
195                         }
196                 }
197         }
198 }
199
200 ### Parsing Multipart Form Data ###
201
202 my $boundary;
203 my $boundary_len;
204 my $mp_buffer;
205 my $mp_buffer_i;
206 my $mp_buffer_boundary;
207 my $mp_eof;
208
209 sub refill_mp_data($) {
210         my ($more) = @_;
211         if ($mp_buffer_boundary >= $mp_buffer_i) {
212                 return $mp_buffer_boundary - $mp_buffer_i;
213         } elsif ($mp_buffer_i + $more <= length($mp_buffer) - $boundary_len) {
214                 return $more;
215         } else {
216                 if ($mp_buffer_i) {
217                         $mp_buffer = substr($mp_buffer, $mp_buffer_i);
218                         $mp_buffer_i = 0;
219                 }
220                 while ($mp_buffer_i + $more > length($mp_buffer) - $boundary_len) {
221                         last if $mp_eof;
222                         my $data;
223                         my $n = read(STDIN, $data, 2048);
224                         if ($n > 0) {
225                                 $mp_buffer .= $data;
226                         } else {
227                                 $mp_eof = 1;
228                         }
229                 }
230                 $mp_buffer_boundary = index($mp_buffer, $boundary, $mp_buffer_i);
231                 if ($mp_buffer_boundary >= 0) {
232                         return $mp_buffer_boundary;
233                 } elsif ($mp_eof) {
234                         return length($mp_buffer);
235                 } else {
236                         return length($mp_buffer) - $boundary_len;
237                 }
238         }
239 }
240
241 sub get_mp_line($) {
242         my ($allow_empty) = @_;
243         my $n = refill_mp_data(1024);
244         my $i = index($mp_buffer, "\r\n", $mp_buffer_i);
245         if ($i >= $mp_buffer_i && $i < $mp_buffer_i + $n - 1) {
246                 my $s = substr($mp_buffer, $mp_buffer_i, $i - $mp_buffer_i);
247                 $mp_buffer_i = $i + 2;
248                 return $s;
249         } elsif ($allow_empty) {
250                 if ($n) {                                                       # An incomplete line
251                         my $s = substr($mp_buffer, $mp_buffer_i, $n);
252                         $mp_buffer_i += $n;
253                         return $s;
254                 } else {                                                        # No more lines
255                         return undef;
256                 }
257         } else {
258                 http_error "400 Bad Request: Premature end of multipart POST data";
259         }
260 }
261
262 sub skip_mp_boundary() {
263         if ($mp_buffer_boundary != $mp_buffer_i) {
264                 http_error "400 Bad Request: Premature end of multipart POST data";
265         }
266         $mp_buffer_boundary = -1;
267         $mp_buffer_i += 2;
268         my $b = get_mp_line(0);
269         print STDERR "SEP $b\n" if $debug;
270         $mp_buffer_boundary = index($mp_buffer, $boundary, $mp_buffer_i);
271         if (substr("\r\n$b", 0, $boundary_len) eq "$boundary--") {
272                 return 0;
273         } else {
274                 return 1;
275         }
276 }
277
278 sub parse_mp_header() {
279         my $h = {};
280         my $last;
281         while ((my $l = get_mp_line(0)) ne "") {
282                 print STDERR "HH $l\n" if $debug;
283                 if (my ($name, $value) = ($l =~ /([A-Za-z0-9-]+)\s*:\s*(.*)/)) {
284                         $name =~ tr/A-Z/a-z/;
285                         $h->{$name} = $value;
286                         $last = $name;
287                 } elsif ($l =~ /^\s+/ && $last) {
288                         $h->{$last} .= $l;
289                 } else {
290                         $last = undef;
291                 }
292         }
293         foreach my $n (keys %$h) {
294                 $h->{$n} = rfc822_prepare($h->{$n});
295                 print STDERR "H $n: $h->{$n}\n" if $debug;
296         }
297         return (keys %$h) ? $h : undef;
298 }
299
300 sub parse_multipart_form_data() {
301         # First of all, find the boundary string
302         my $ct = rfc822_prepare($ENV{"CONTENT_TYPE"});
303         if (!(($boundary) = ($ct =~ /^.*;\s*boundary=([^; ]+)/))) {
304                 http_error "400 Bad Request: Multipart content with no boundary string received";
305         }
306         $boundary = rfc822_deescape($boundary);
307         print STDERR "BOUNDARY IS $boundary\n" if $debug;
308
309         # BUG: IE 3.01 on Macintosh forgets to add the "--" at the start of the boundary string
310         # as the MIME specs preach. Workaround borrowed from CGI.pm in Perl distribution.
311         my $agent = http_get("User-Agent") // "";
312         $boundary = "--$boundary" unless $agent =~ /MSIE\s+3\.0[12];\s*Mac/;
313         $boundary = "\r\n$boundary";
314         $boundary_len = length($boundary) + 2;
315
316         # Check upload size in advance
317         if (my $size = http_get("Content-Length")) {
318                 my $max_allowed = 0;
319                 foreach my $a (values %$main_arg_table) {
320                         $max_allowed += $a->{"maxsize"} || 65536;
321                 }
322                 if ($size > $max_allowed) {
323                         http_error "413 Request Entity Too Large";
324                 }
325         }
326
327         # Initialize our buffering mechanism and part splitter
328         $mp_buffer = "\r\n";
329         $mp_buffer_i = 0;
330         $mp_buffer_boundary = -1;
331         $mp_eof = 0;
332
333         # Skip garbage before the 1st part
334         while (my $i = refill_mp_data(256)) { $mp_buffer_i += $i; }
335         skip_mp_boundary() || return;
336
337         # Process individual parts
338         do { PART: {
339                 print STDERR "NEXT PART\n" if $debug;
340                 my $h = parse_mp_header();
341                 my ($field, $cdisp, $a);
342                 if ($h &&
343                     ($cdisp = $h->{"content-disposition"}) &&
344                     $cdisp =~ /^form-data/ &&
345                     (($field) = ($cdisp =~ /;name=([^;]+)/)) &&
346                     ($a = $main_arg_table->{"$field"})) {
347                         print STDERR "FIELD $field\n" if $debug;
348                         if (defined $h->{"content-transfer-encoding"}) {
349                                 http_error "400 Bad Request: Unexpected Content-Transfer-Encoding";
350                         }
351                         if (defined $a->{"var"}) {
352                                 while (defined (my $l = get_mp_line(1))) {
353                                         print STDERR "VALUE $l\n" if $debug;
354                                         parse_raw_args_ll($field, $l);
355                                 }
356                                 next PART;
357                         } elsif (defined $a->{"file"}) {
358                                 require File::Temp;
359                                 require IO::Handle;
360                                 my $max_size = $a->{"maxsize"} || 1048576;
361                                 my @tmpargs = (undef, UNLINK => 1);
362                                 push @tmpargs, DIR => $a->{"tmpdir"} if defined $a->{"tmpdir"};
363                                 my ($fh, $fn) = File::Temp::tempfile(@tmpargs);
364                                 print STDERR "FILE UPLOAD to $fn\n" if $debug;
365                                 ${$a->{"file"}} = $fn;
366                                 ${$a->{"fh"}} = $fh if defined $a->{"fh"};
367                                 my $total_size = 0;
368                                 while (my $i = refill_mp_data(4096)) {
369                                         print $fh substr($mp_buffer, $mp_buffer_i, $i);
370                                         $mp_buffer_i += $i;
371                                         $total_size += $i;
372                                         if ($total_size > $max_size) { http_error "413 Request Entity Too Large"; }
373                                 }
374                                 $fh->flush();   # Don't close the handle, the file would disappear otherwise
375                                 next PART;
376                         }
377                 }
378                 print STDERR "SKIPPING\n" if $debug;
379                 while (my $i = refill_mp_data(256)) { $mp_buffer_i += $i; }
380         } } while (skip_mp_boundary());
381 }
382
383 ### Generating Self-ref URL's ###
384
385 sub make_out_args(@) {          # Usage: make_out_args([arg_table, ...] name => value, ...)
386         my @arg_tables = ( $main_arg_table );
387         while (@_ && ref($_[0]) eq 'HASH') {
388                 push @arg_tables, shift @_;
389         }
390         my %overrides = @_;
391         my $out = {};
392         for my $table (@arg_tables) {
393                 for my $name (keys %$table) {
394                         my $arg = $table->{$name};
395                         defined($arg->{'var'}) || next;
396                         defined($arg->{'pass'}) && !$arg->{'pass'} && !exists $overrides{$name} && next;
397                         defined $arg->{'default'} or $arg->{'default'} = "";
398                         my $value;
399                         if (!defined($value = $overrides{$name})) {
400                                 if (exists $overrides{$name}) {
401                                         $value = $arg->{'default'};
402                                 } else {
403                                         $value = ${$arg->{'var'}};
404                                         defined $value or $value = $arg->{'default'};
405                                 }
406                         }
407                         if ($value ne $arg->{'default'}) {
408                                 $out->{$name} = $value;
409                         }
410                 }
411         }
412         return $out;
413 }
414
415 sub self_ref(@) {
416         my $out = make_out_args(@_);
417         return "?" . join(':', map { "$_=" . url_param_escape($out->{$_}) } sort keys %$out);
418 }
419
420 sub self_form(@) {
421         my $out = make_out_args(@_);
422         return join('', map { "<input type=hidden name=$_ value='" . html_escape($out->{$_}) . "'>\n" } sort keys %$out);
423 }
424
425 ### Cookies
426
427 sub set_cookie($$@) {
428         #
429         # Unfortunately, the support for the new cookie standard (RFC 2965) among
430         # web browsers is still very scarce, so we are still using the old Netscape
431         # specification.
432         #
433         # Usage: set_cookie(name, value, option => value...), where options are:
434         #
435         #       max-age         maximal age in seconds
436         #       domain          domain name scope
437         #       path            path name scope
438         #       secure          if present, cookie applies only to SSL connections
439         #                       (in this case, the value should be undefined)
440         #       discard         if present with any value, the cookie is discarded
441         #
442
443         my $key = shift @_;
444         my $value = shift @_;
445         my %other = @_;
446         if (exists $other{'discard'}) {
447                 delete $other{'discard'};
448                 $other{'max-age'} = 0;
449         }
450         if (defined(my $age = $other{'max-age'})) {
451                 delete $other{'max-age'};
452                 my $exp = ($age ? (time + $age) : 0);
453                 # Avoid problems with locales
454                 my ($S,$M,$H,$d,$m,$y,$wd) = gmtime $exp;
455                 my @wdays = ( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' );
456                 my @mons = ( 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' );
457                 $other{'expires'} = sprintf("%s, %02d-%s-%d %02d:%02d:%02d GMT",
458                         $wdays[$wd], $d, $mons[$m], $y+1900, $H, $M, $S);
459         }
460
461         print "Set-Cookie: $key=", url_escape($value);
462         foreach my $k (keys %other) {
463                 print "; $k";
464                 print "=", $other{$k} if defined $other{$k};
465         }
466         print "\n";
467 }
468
469 sub parse_cookies() {
470         my $h = http_get("Cookie") or return ();
471         my @cook = ();
472         foreach my $x (split /;\s*/, $h) {
473                 my ($k,$v) = split /=/, $x;
474                 $v = url_deescape($v) if defined $v;
475                 push @cook, $k => $v;
476         }
477         return @cook;
478 }
479
480 1;  # OK