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   14261 use warnings;
  20         83  
  20         1072  
26 20     20   144 use strict;
  20         58  
  20         489  
27 20     20   9886 use CGI::Cookie;
  20         103136  
  20         889  
28 20     20   10168 use POSIX qw(mktime);
  20         131916  
  20         571  
29 20     20   28581 use XAO::Cache;
  20         46  
  20         573  
30 20     20   108 use XAO::Errors qw(XAO::DO::Web::Config);
  20         40  
  20         130  
31 20     20   7849 use XAO::Objects;
  20         50  
  20         460  
32 20     20   111 use XAO::Utils;
  20         50  
  20         1439  
33              
34 20     20   147 use base XAO::Objects->load(objname => 'Embeddable');
  20         52  
  20         87  
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   92 my ($self,$cookie_name)=@_;
70              
71 48         63 my $defaults;
72              
73 48         124 my $base_config=$self->base_config;
74 48 50 33     417 if($base_config && $base_config->can('get')) {
75 48 100       1002 if(my $cookie_config=$base_config->get('/xao/cookie')) {
76 25 100       1702 foreach my $cf ($cookie_config->{'common'}, (exists $cookie_config->{$cookie_name} ? ($cookie_config->{$cookie_name}) : ())) {
77 32 50       66 $cf || next;
78              
79 32         78 foreach my $n (keys %$cf) {
80 106 100       237 my $nn=$n =~ /^-/ ? $n : '-'.lc($n);
81 106         231 $defaults->{$nn}=$cf->{$n};
82             }
83             }
84             }
85             }
86              
87 48         955 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 4070 my $self=shift;
153 41 100       137 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       454 if(!ref($cookie)) {
159 4         26 eprint "Passing baked cookies to ".$self."::add_cookie() is STRONGLY DEPRECATED!";
160              
161 20     20   26678 use CGI::Util qw();
  20         43  
  20         44095  
162              
163             # Expecting something like:
164             # foo=bar; path=/; expires=Wed, 16-Dec-2015 03:32:33 GMT
165             #
166 4         208 my ($nv,@params)=split(/\s*;\s*/,$cookie);
167              
168 4         10 my ($name,$value);
169 4 50       30 if($nv=~/^\s*(.+)\s*=\s*(.*?)\s*$/) {
170 4         14 $name=CGI::Util::unescape($1);
171 4         71 $value=[map { CGI::Util::unescape($_) } split(/&/,$2)];
  4         9  
172 4 50       65 $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         15 my %chash=(
180             -name => $name,
181             -value => $value,
182             );
183              
184 4         10 foreach my $p (@params) {
185 8 50       49 $p=~/^\s*(.+)\s*=\s*(.*?)\s*$/ || next;
186 8         26 my ($pn,$pv)=(lc($1),$2);
187 8 50       26 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         11 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         12 $cookie=\%chash;
197             }
198              
199 41 50       89 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         88 my $defaults=$self->_cookie_config($cookie->{'-name'});
207 41 100       111 $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     437 if($cookie->{'-sscompat'} && $cookie->{'-samesite'}) {
214             $self->add_cookie(merge_refs($cookie, {
215 5         23 -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     106 if($cookie->{'-domain'} && ref($cookie->{'-domain'})) {
224 2         8 my $dlist=$cookie->{'-domain'};
225 2         6 foreach my $domain (@$dlist) {
226 4         17 $self->add_cookie(merge_refs($cookie,{
227             -domain => $domain,
228             }));
229             }
230 2         17 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       81 if($self->{'cookies'}) {
236 27         87 my $cnew=CGI::Cookie->new($cookie);
237              
238 27         7048 for(my $i=0; $i!=@{$self->{'cookies'}}; $i++) {
  114         1267  
239 96         153 my $c=$self->{'cookies'}->[$i];
240              
241 96 50 33     371 next unless ref($c) && ref($c) eq 'HASH';
242              
243 96         219 my $cstored=CGI::Cookie->new($c);
244              
245 96         25326 my $dnew=$cnew->domain();
246 96         553 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     532 $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         212 $self->{'cookies'}->[$i]=$cookie;
258              
259 9         55 return $cookie;
260             }
261             }
262              
263 30         38 push(@{$self->{'cookies'}},$cookie);
  30         75  
264              
265 30         107 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 1972 my ($self,$newcgi)=@_;
293              
294 352 100       1225 return $self->{'cgi'} unless $newcgi;
295              
296 140 50       413 if($self->{'special_access'}) {
297 140         267 $self->{'cgi'}=$newcgi;
298 140         312 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 13680 my $self=shift;
316 270         602 delete $self->{'cgi'};
317 270         827 delete $self->{'clipboard'};
318 270         422 delete $self->{'cookies'};
319 270         438 delete $self->{'header_args'};
320 270         358 delete $self->{'force_byte_output'};
321 270         387 delete $self->{'header_printed'};
322 270         580 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 6467 my $self=shift;
336 1105 100       3032 $self->{'clipboard'}=XAO::SimpleHash->new() unless $self->{'clipboard'};
337 1105         3760 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 412 my $self=shift;
350              
351 23         31 my @baked;
352 23         35 foreach my $c (@{$self->{'cookies'}}) {
  23         58  
353 30 50 33     4656 if(ref($c) && ref($c) eq 'HASH') {
354 30         43 push @baked,CGI::Cookie->new(%{$c});
  30         134  
355             }
356             else {
357 0         0 push @baked,$c;
358             }
359             }
360              
361 23         2933 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 1223 my $self=shift;
374 132         334 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 3294 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 1374 my $self=shift;
411 140         389 $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 3919 my ($self,$value)=@_;
429 119 100       276 if(defined $value) {
430 33         55 $self->{'force_byte_output'}=$value;
431             }
432 119         339 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 63 my $self=shift;
457              
458 11 50       24 return undef if $self->{'header_printed'};
459              
460 11 50       24 $self->header_args(@_) if @_;
461              
462 11         21 $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 19 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     24 my $header_args=$self->{'header_args'} || { };
498              
499             return (
500 11   50     68 '-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 1996 my $self=shift;
534 315         886 my $args=get_args(\@_);
535              
536 315         3759 @{$self->{'header_args'}}{map { $self->header_normalize($_) } keys %{$args}}=values %{$args};
  315         1052  
  394         778  
  315         705  
  315         837  
537              
538 315         665 my @todrop=grep { ! defined $self->{'header_args'}->{$_} } keys %{$self->{'header_args'}};
  779         1655  
  315         703  
539 315 100       828 delete @{$self->{'header_args'}}{@todrop} if @todrop;
  1         3  
540              
541 315         1058 return $self->{'header_args'};
542             }
543              
544             ###############################################################################
545              
546             sub header_normalize ($$) {
547 395     395 0 730 my ($self,$header)=@_;
548              
549 395         855 $header=lc($header);
550 395         1311 $header=~s/-/_/g;
551 395         1322 $header=~s/^_+//;
552              
553 395         1065 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 49 my $self=shift;
568              
569 1         1 delete @{$self->{'header_args'}}{map { $self->header_normalize($_) } @_};
  1         8  
  1         3  
570              
571 1         9 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 14079 my ($self,$name,$original)=@_;
591              
592 54 50 33     282 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         81 my $value;
601              
602 54 100       102 if(!$original) {
603 40 100       53 foreach my $c (reverse @{$self->{'cookies'} || []}) {
  40         131  
604 105         731 my $cookie=CGI::Cookie->new($c);
605              
606 105 100       28471 if($cookie->name() eq $name) {
607 26         186 my $value=$cookie->value;
608              
609 26         166 my $expires_text=$cookie->expires;
610              
611 26 50       280 if($expires_text =~ /(\d{2})\W+([a-z]{3})\W+(\d{4})\W+(\d{2})\W+(\d{2})\W+(\d{2})/i) {
612 26         86 my $midx=index('janfebmaraprmayjunjulaugsepoctnovdec',lc($2));
613 26 50       54 if($midx>=0) {
614 26         44 $midx/=3;
615 26         149 local($ENV{'TZ'})='UTC';
616 26         188 my $expires=mktime($6,$5,$4,$1,$midx,$3-1900);
617 26 100       110 if($expires <= time) {
618 4         16 $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         141 return $value;
630             }
631             }
632             }
633              
634 28         86 my $cgi=$self->cgi;
635              
636 28 50       66 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         49 $value=$self->cgi->cookie($name);
650 28 100       30936 if(!defined $value) {
651 7         19 my $defaults=$self->_cookie_config($name);
652 7 100       21 if($defaults->{'-sscompat'}) {
653 2         14 $value=$self->cgi->cookie($name.'-sscompat');
654             }
655             }
656              
657 28         7146 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 1841 my $proto=shift;
670 38   33     586 bless {},ref($proto) || $proto;
671             }
672              
673             ###############################################################################
674             1;
675             __END__