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