File Coverage

blib/lib/XAO/DO/Web/Config.pm
Criterion Covered Total %
statement 178 200 89.0
branch 58 86 67.4
condition 20 34 58.8
subroutine 27 28 96.4
pod 14 17 82.3
total 297 365 81.3


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             XAO::DO::Web::Config - XAO::Web site configuration object
4              
5             =head1 SYNOPSIS
6              
7             sub init {
8             my $self=shift;
9              
10             my $webconfig=XAO::Objects->new(objname => 'Web::Config');
11              
12             $self->embed(web => $webconfig);
13             }
14              
15             =head1 DESCRIPTION
16              
17             This object provides methods specifically for XAO::Web objects. It is
18             supposed to be embedded into XAO::DO::Config object by a web server
19             handler when site is initialized.
20              
21             =cut
22              
23             ###############################################################################
24             package XAO::DO::Web::Config;
25 20     20   12734 use warnings;
  20         64  
  20         1010  
26 20     20   142 use strict;
  20         53  
  20         438  
27 20     20   8088 use CGI::Cookie;
  20         85415  
  20         886  
28 20     20   9091 use POSIX qw(mktime);
  20         113144  
  20         628  
29 20     20   23996 use XAO::Cache;
  20         56  
  20         528  
30 20     20   93 use XAO::Errors qw(XAO::DO::Web::Config);
  20         42  
  20         136  
31 20     20   7473 use XAO::Objects;
  20         36  
  20         393  
32 20     20   91 use XAO::Utils;
  20         36  
  20         1153  
33              
34 20     20   110 use base XAO::Objects->load(objname => 'Embeddable');
  20         51  
  20         110  
35              
36             our $VERSION='2.004'; # Obsolete, but needed by CPAN
37              
38             # Prototypes
39             #
40             sub add_cookie ($@);
41             sub cgi ($$);
42             sub cleanup ($);
43             sub clipboard ($);
44             sub cookies ($);
45             sub disable_special_access ($);
46             sub embeddable_methods ($);
47             sub enable_special_access ($);
48             sub force_byte_output ($;$);
49             sub get_cookie ($$;$);
50             sub header ($@);
51             sub header_args ($@);
52             sub header_array ($);
53             sub header_normalize ($$);
54             sub header_printed ($);
55             sub header_remove ($@);
56             sub new ($@);
57              
58             ###############################################################################
59              
60             =head1 METHODS
61              
62             =over
63              
64             =cut
65              
66             ###############################################################################
67              
68             sub _cookie_config ($$) {
69 48     48   69 my ($self,$cookie_name)=@_;
70              
71 48         51 my $defaults;
72              
73 48         110 my $base_config=$self->base_config;
74 48 50 33     328 if($base_config && $base_config->can('get')) {
75 48 100       815 if(my $cookie_config=$base_config->get('/xao/cookie')) {
76 25 100       1420 foreach my $cf ($cookie_config->{'common'}, (exists $cookie_config->{$cookie_name} ? ($cookie_config->{$cookie_name}) : ())) {
77 32 50       54 $cf || next;
78              
79 32         64 foreach my $n (keys %$cf) {
80 106 100       184 my $nn=$n =~ /^-/ ? $n : '-'.lc($n);
81 106         185 $defaults->{$nn}=$cf->{$n};
82             }
83             }
84             }
85             }
86              
87 48         788 return $defaults;
88             }
89              
90             ###############################################################################
91              
92             =item add_cookie (@)
93              
94             Adds an HTTP cookie into the internal list. Parameters are a hash in the
95             same format as for CGI->cookie() method (see L).
96              
97             If a cookie with the same name, path (and domain if set) is already in
98             the list from a previous call to add_cookie() then it gets replaced.
99              
100             Think of it as if you are adding cookies to you final HTTP response as
101             XAO::Web handler will get all the cookies collected during template
102             processing and send them out for you.
103              
104             Examples:
105              
106             $config->add_cookie($cookie);
107              
108             $config->add_cookie(
109             -name => 'sessionID',
110             -value => 'xyzzy',
111             -expires =>'+1h',
112             );
113              
114             For convenience, if there is a '-domain' argument and it refers to a
115             list of domains the cookie is expanded into a set of cookies for all
116             these domains.
117              
118             Parameters may also be configured as defaults or for specific cookie
119             names in the site configuration /xao/cookie section:
120              
121             'xao' => {
122             'cookie' => {
123             'common' => { # All cookies
124             'httponly' => 1,
125             'secure' => 1,
126             },
127             'sessionid' => { # Specific cookie
128             'samesite' => 'None',
129             },
130              
131             Due to incompatible ways various browsers treat "SameSite" cookies a
132             special parameter 'sscompat' can be used to create and read two cookies --
133             one with "SameSite" value and one without. This alters the behavior of
134             both add_cookie() and get_cookie() methods for simplicity.
135              
136             'sessionid' => {
137             'samesite' => 'None',
138             'sscompat' => 1,
139             },
140              
141             $siteconfig->add_cookie(-name => 'sessionid', -value => $sessionid);
142              
143             Set-Cookie: ...; sessionid=12345; Secure; HttpOnly; SameSite=None
144             Set-Cookie: ...; sessionid-sscompat=12345; Secure; HttpOnly
145              
146             The get_cookie() method takes into consideration values that were set
147             with add_cookie() as a priority over CGI cookies received.
148              
149             =cut
150              
151             sub add_cookie ($@) {
152 41     41 1 3179 my $self=shift;
153 41 100       115 my $cookie=(@_==1 ? $_[0] : get_args(\@_));
154              
155             # We should only be getting hash based cookie data. Attempting to
156             # unbake it if we got a baked one.
157             #
158 41 100       341 if(!ref($cookie)) {
159 4         24 eprint "Passing baked cookies to ".$self."::add_cookie() is STRONGLY DEPRECATED!";
160              
161 20     20   24113 use CGI::Util qw();
  20         43  
  20         36434  
162              
163             # Expecting something like:
164             # foo=bar; path=/; expires=Wed, 16-Dec-2015 03:32:33 GMT
165             #
166 4         183 my ($nv,@params)=split(/\s*;\s*/,$cookie);
167              
168 4         8 my ($name,$value);
169 4 50       26 if($nv=~/^\s*(.+)\s*=\s*(.*?)\s*$/) {
170 4         9 $name=CGI::Util::unescape($1);
171 4         56 $value=[map { CGI::Util::unescape($_) } split(/&/,$2)];
  4         7  
172 4 50       49 $value=$value->[0] if @$value==1;
173             }
174             else {
175 0         0 eprint "Unparsable baked cookie '$cookie' in add_cookie(), NOT SET";
176 0         0 return;
177             }
178              
179 4         13 my %chash=(
180             -name => $name,
181             -value => $value,
182             );
183              
184 4         8 foreach my $p (@params) {
185 8 50       40 $p=~/^\s*(.+)\s*=\s*(.*?)\s*$/ || next;
186 8         23 my ($pn,$pv)=(lc($1),$2);
187 8 50       19 if ($pn eq 'domain') { $chash{'-domain'}=$pv; }
  0 100       0  
    50          
    0          
    0          
    0          
    0          
188 4         10 elsif($pn eq 'path') { $chash{'-path'}=$pv; }
189 4         9 elsif($pn eq 'expires') { $chash{'-expires'}=$pv; }
190 0         0 elsif($pn eq 'max-age') { $chash{'-max-age'}=$pv; }
191 0         0 elsif($pn eq 'secure') { $chash{'-secure'}=$pv; }
192 0         0 elsif($pn eq 'httponly'){ $chash{'-httponly'}=$pv; }
193 0         0 elsif($pn eq 'samesite'){ $chash{'-samesite'}=$pv; }
194             }
195              
196 4         8 $cookie=\%chash;
197             }
198              
199 41 50       72 if(!$cookie->{'-name'}) {
200 0         0 eprint "No cookie name given to ".ref($self)."::add_cookie()";
201 0         0 return;
202             }
203              
204             # Applying configuration values, if any.
205             #
206 41         74 my $defaults=$self->_cookie_config($cookie->{'-name'});
207 41 100       91 $cookie=merge_refs($defaults, $cookie) if $defaults;
208              
209             # SameSite parameter is not implemented the same in all
210             # browsers. When requested we set an additional cookie without that
211             # parameter to work in incompatible browsers.
212             #
213 41 50 66     344 if($cookie->{'-sscompat'} && $cookie->{'-samesite'}) {
214             $self->add_cookie(merge_refs($cookie, {
215 5         20 -name => $cookie->{'-name'}.'-sscompat',
216             -sscompat => undef,
217             -samesite => undef,
218             }));
219             }
220              
221             # Recursively expanding if multiple domains are given.
222             #
223 41 100 100     98 if($cookie->{'-domain'} && ref($cookie->{'-domain'})) {
224 2         4 my $dlist=$cookie->{'-domain'};
225 2         3 foreach my $domain (@$dlist) {
226 4         13 $self->add_cookie(merge_refs($cookie,{
227             -domain => $domain,
228             }));
229             }
230 2         8 return;
231             }
232             # If the new cookie has the same name, domain and path
233             # as previously set one - we replace it.
234             #
235 39 100       69 if($self->{'cookies'}) {
236 27         64 my $cnew=CGI::Cookie->new($cookie);
237              
238 27         5624 for(my $i=0; $i!=@{$self->{'cookies'}}; $i++) {
  114         1089  
239 96         115 my $c=$self->{'cookies'}->[$i];
240              
241 96 50 33     323 next unless ref($c) && ref($c) eq 'HASH';
242              
243 96         189 my $cstored=CGI::Cookie->new($c);
244              
245 96         20439 my $dnew=$cnew->domain();
246 96         443 my $dstored=$cstored->domain();
247              
248             ### dprint "...comparing ",$cnew->name()," with ",$cstored->name(),"; path='",$cnew->path(),"' vs '",$cstored->path(),"'; domain='",$dnew,"' vs '",$dstored,"'";
249              
250             next unless
251 96 100 66     422 $cnew->name() eq $cstored->name() &&
      100        
      100        
252             $cnew->path() eq $cstored->path() &&
253             ((!defined($dnew) && !defined($dstored)) || (defined($dnew) && defined($dstored) && $dnew eq $dstored));
254              
255             ### dprint "....override!";
256              
257 9         176 $self->{'cookies'}->[$i]=$cookie;
258              
259 9         43 return $cookie;
260             }
261             }
262              
263 30         31 push(@{$self->{'cookies'}},$cookie);
  30         62  
264              
265 30         113 return $cookie;
266             }
267              
268             ###############################################################################
269              
270             =item cgi (;$)
271              
272             Returns or sets standard CGI object (see L). In future versions this
273             would probably be converted to CGI::Lite or something similar, so do not
274             rely to much on the functionality of CGI.
275              
276             Obviously you should not call this method to set CGI object unless you
277             are 100% sure you know what you're doing. And even in that case you have
278             to call enable_special_access() in advance.
279              
280             Example:
281              
282             my $cgi=$self->cgi;
283             my $name=$cgi->param('name');
284              
285             Or just:
286              
287             my $name=$self->cgi->param('name');
288              
289             =cut
290              
291             sub cgi ($$) {
292 352     352 1 1695 my ($self,$newcgi)=@_;
293              
294 352 100       1013 return $self->{'cgi'} unless $newcgi;
295              
296 140 50       362 if($self->{'special_access'}) {
297 140         229 $self->{'cgi'}=$newcgi;
298 140         258 return $newcgi;
299             }
300              
301             throw XAO::E::DO::Web::Config
302 0         0 "cgi - storing new CGI requires enable_special_access()";
303             }
304              
305             ###############################################################################
306              
307             =item cleanup ()
308              
309             Removes CGI object, cleans up clipboard. No need to call manually,
310             usually is called as part of XAO::DO::Config cleanup().
311              
312             =cut
313              
314             sub cleanup ($) {
315 270     270 1 11451 my $self=shift;
316 270         508 delete $self->{'cgi'};
317 270         718 delete $self->{'clipboard'};
318 270         341 delete $self->{'cookies'};
319 270         341 delete $self->{'header_args'};
320 270         301 delete $self->{'force_byte_output'};
321 270         290 delete $self->{'header_printed'};
322 270         476 delete $self->{'special_access'};
323             }
324              
325             ###############################################################################
326              
327             =item clipboard ()
328              
329             Returns clipboard XAO::SimpleHash object. Useful to keep temporary data
330             between different XAO::Web objects. Cleaned up for every session.
331              
332             =cut
333              
334             sub clipboard ($) {
335 1105     1105 1 5395 my $self=shift;
336 1105 100       2532 $self->{'clipboard'}=XAO::SimpleHash->new() unless $self->{'clipboard'};
337 1105         3225 return $self->{'clipboard'};
338             }
339              
340             ###############################################################################
341              
342             =item cookies ()
343              
344             Returns reference to an array of prepared cookies.
345              
346             =cut
347              
348             sub cookies ($) {
349 23     23 1 329 my $self=shift;
350              
351 23         35 my @baked;
352 23         32 foreach my $c (@{$self->{'cookies'}}) {
  23         49  
353 30 50 33     3697 if(ref($c) && ref($c) eq 'HASH') {
354 30         39 push @baked,CGI::Cookie->new(%{$c});
  30         116  
355             }
356             else {
357 0         0 push @baked,$c;
358             }
359             }
360              
361 23         2355 return \@baked;
362             }
363              
364             ###############################################################################
365              
366             =item disable_special_access ()
367              
368             Disables use of cgi() method to set a new value.
369              
370             =cut
371              
372             sub disable_special_access ($) {
373 132     132 1 914 my $self=shift;
374 132         274 delete $self->{special_access};
375             }
376              
377             ###############################################################################
378              
379             =item embeddable_methods ()
380              
381             Used internally by global Config object, returns an array with all
382             embeddable method names -- add_cookie(), cgi(), clipboard(), cookies(),
383             force_byte_output(), header(), header_args().
384              
385             =cut
386              
387             sub embeddable_methods ($) {
388 38     38 1 2922 qw(
389             add_cookie cgi clipboard cookies force_byte_output
390             header header_args header_array header_remove get_cookie
391             );
392             }
393              
394             ###############################################################################
395              
396             =item enable_special_access ()
397              
398             Enables use of cgi() method to set a new value. Normally you do
399             not need this method.
400              
401             Example:
402              
403             $config->enable_special_access();
404             $config->cgi(CGI->new());
405             $config->disable_special_access();
406              
407             =cut
408              
409             sub enable_special_access ($) {
410 140     140 1 1231 my $self=shift;
411 140         282 $self->{special_access}=1;
412             }
413              
414             ###############################################################################
415              
416             =item force_byte_output ()
417              
418             If the site is configured to run in character mode it might still be
419             necessary to output some content as is, without character processing
420             (e.g. for generated images or spreadsheets).
421              
422             This method is called automatically when content type is set to a
423             non-text value, so normally there is no need to call it directly.
424              
425             =cut
426              
427             sub force_byte_output ($;$) {
428 119     119 1 3586 my ($self,$value)=@_;
429 119 100       220 if(defined $value) {
430 33         50 $self->{'force_byte_output'}=$value;
431             }
432 119         337 return $self->{'force_byte_output'};
433             }
434              
435             ###############################################################################
436              
437             =item header (@)
438              
439             Returns HTTP header. The same as $cgi->header and accepts the same
440             parameters. Cookies added before by add_cookie() method are also
441             included in the header.
442              
443             Returns header only once, on subsequent calls returns undef.
444              
445             B In mod_perl environment CGI will send the header itself and
446             return empty string. Be carefull to check the result for
447             C instead of just C!
448              
449             As with the most of Web::Config methods you do not need this method
450             normally. It is called automatically by web server handler at the end of
451             a session before sending out session results.
452              
453             =cut
454              
455             sub header ($@) {
456 11     11 1 49 my $self=shift;
457              
458 11 50       20 return undef if $self->{'header_printed'};
459              
460 11 50       20 $self->header_args(@_) if @_;
461              
462 11         15 $self->{'header_printed'}=1;
463              
464 11         21 return $self->cgi->header($self->header_array());
465             }
466              
467             ###############################################################################
468              
469             sub header_array ($) {
470 11     11 0 17 my $self=shift;
471              
472             # There is a silly bug (or a truly misguided undocumented feature)
473             # in CGI. It works with headers correctly only if the first header
474             # it gets starts with a dash. We used to supply CGI::header() with a
475             # hash and that resulted in sometimes un-dashed elements getting to
476             # be the first in the list, resulting in mayhem -- completely broken
477             # header output like this sent without any warnings:
478             #
479             # HTTP/1.0 foo
480             # Server: Apache
481             # Status: foo
482             # Window-Target: ARRAY(0xc5f5e8)
483             # P3P: policyref="/w3c/p3p.xml", CP="-expires"
484             # Set-Cookie: -cookie
485             # Expires: -Charset
486             # Date: Thu, 07 Aug 2014 22:41:35 GMT
487             # Content-Disposition: attachment; filename="no-cache"
488             # Now
489             # Content-Type: P3P; charset=-cache_control
490             #
491             # This never happened in years of using perl below version 5.18,
492             # probably due to different internal hash algorithm that never
493             # put undashed elements to the front.
494             #
495             # Using the always present '-cookie' header to fill the front row.
496             #
497 11   50     21 my $header_args=$self->{'header_args'} || { };
498              
499             return (
500 11   50     49 '-cookie' => ($header_args->{'-cookie'} || $header_args->{'Cookie'} || $self->cookies || []),
501             %$header_args,
502             );
503             }
504              
505             ###############################################################################
506              
507             sub header_printed ($) {
508 0     0 0 0 my $self=shift;
509 0         0 return $self->{'header_printed'};
510             }
511              
512             ###############################################################################
513              
514             =item header_args (%)
515              
516             Sets some parameters for header generation. You can use it to change
517             page status for example:
518              
519             $config->header_args(-Status => '404 File not found');
520              
521             Accepts the same arguments CGI->header() accepts.
522              
523             Header names can be any of 'Header-Name', 'header-name', 'header_name',
524             or '-Header_name'. All variants are normalized to all-lowercase
525             underscored to make values assigned later in the code trump the earlier.
526              
527             Supplying 'undef' as a header value is the same as removing that header
528             with header_remove().
529              
530             =cut
531              
532             sub header_args ($@) {
533 315     315 1 1806 my $self=shift;
534 315         798 my $args=get_args(\@_);
535              
536 315         3142 @{$self->{'header_args'}}{map { $self->header_normalize($_) } keys %{$args}}=values %{$args};
  315         834  
  394         637  
  315         561  
  315         691  
537              
538 315         495 my @todrop=grep { ! defined $self->{'header_args'}->{$_} } keys %{$self->{'header_args'}};
  779         1381  
  315         618  
539 315 100       686 delete @{$self->{'header_args'}}{@todrop} if @todrop;
  1         12  
540              
541 315         892 return $self->{'header_args'};
542             }
543              
544             ###############################################################################
545              
546             sub header_normalize ($$) {
547 395     395 0 573 my ($self,$header)=@_;
548              
549 395         665 $header=lc($header);
550 395         1070 $header=~s/-/_/g;
551 395         1081 $header=~s/^_+//;
552              
553 395         902 return $header;
554             }
555              
556             ###############################################################################
557              
558             =item header_remove (@)
559              
560             Remove one or more headers that were previously set in the same session.
561              
562             $config->header_remove('X-Frame-Options');
563              
564             =cut
565              
566             sub header_remove ($@) {
567 1     1 1 44 my $self=shift;
568              
569 1         7 delete @{$self->{'header_args'}}{map { $self->header_normalize($_) } @_};
  1         4  
  1         46  
570              
571 1         4 return $self->{'header_args'};
572             }
573              
574             ###############################################################################
575              
576             =item get_cookie ($;$)
577              
578             Return cookie value for the given cookie name. Unless the second
579             parameter is true, for cookies already set earlier in the same session
580             it would return the value as set, not the value as it was originally
581             received.
582              
583             B The path and domain of cookies is ignored when checking for
584             earlier set cookies and the last cookie stored with that name is
585             returned!
586              
587             =cut
588              
589             sub get_cookie ($$;$) {
590 54     54 1 10936 my ($self,$name,$original)=@_;
591              
592 54 50 33     203 if(!defined $name || !length($name)) {
593 0         0 eprint "No cookie name given to ".ref($self)."::get_cookie()";
594 0         0 for(my $i=0; $i<3; ++$i) {
595 0 0       0 dprint "..STACK: ".join('|',map { defined($_) ? $_ : '' } caller($i));
  0         0  
596             }
597 0         0 return undef;
598             }
599              
600 54         71 my $value;
601              
602 54 100       96 if(!$original) {
603 40 100       45 foreach my $c (reverse @{$self->{'cookies'} || []}) {
  40         106  
604 105         618 my $cookie=CGI::Cookie->new($c);
605              
606 105 100       22514 if($cookie->name() eq $name) {
607 26         154 my $value=$cookie->value;
608              
609 26         133 my $expires_text=$cookie->expires;
610              
611 26 50       186 if($expires_text =~ /(\d{2})\W+([a-z]{3})\W+(\d{4})\W+(\d{2})\W+(\d{2})\W+(\d{2})/i) {
612 26         67 my $midx=index('janfebmaraprmayjunjulaugsepoctnovdec',lc($2));
613 26 50       41 if($midx>=0) {
614 26         36 $midx/=3;
615 26         122 local($ENV{'TZ'})='UTC';
616 26         154 my $expires=mktime($6,$5,$4,$1,$midx,$3-1900);
617 26 100       92 if($expires <= time) {
618 4         13 $value=undef;
619             }
620             }
621             else {
622 0         0 eprint "Invalid month '$2' in cookie '$name' expiration '$expires_text'";
623             }
624             }
625             else {
626 0         0 eprint "Invalid expiration '$expires_text' for cookie '$name'";
627             }
628              
629 26         114 return $value;
630             }
631             }
632             }
633              
634 28         74 my $cgi=$self->cgi;
635              
636 28 50       54 if(!$cgi) {
637 0         0 eprint "Called get_cookie() before CGI is available";
638 0         0 return undef;
639             }
640              
641             # When SameSite is set to None we might not get a value back from
642             # incompatible browsers. See:
643             # https://web.dev/samesite-cookie-recipes/
644             # https://www.chromium.org/updates/same-site/incompatible-clients
645             #
646             # Checking if 'sscompat' variant of this cookie is set, added by
647             # add_cookie() code when configured.
648             #
649 28         39 $value=$self->cgi->cookie($name);
650 28 100       25281 if(!defined $value) {
651 7         16 my $defaults=$self->_cookie_config($name);
652 7 100       17 if($defaults->{'-sscompat'}) {
653 2         6 $value=$self->cgi->cookie($name.'-sscompat');
654             }
655             }
656              
657 28         5603 return $value;
658             }
659              
660             ###############################################################################
661              
662             =item new ($$)
663              
664             Creates a new empty configuration object.
665              
666             =cut
667              
668             sub new ($@) {
669 38     38 1 1596 my $proto=shift;
670 38   33     454 bless {},ref($proto) || $proto;
671             }
672              
673             ###############################################################################
674             1;
675             __END__