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   14581 use warnings;
  20         83  
  20         1169  
26 20     20   152 use strict;
  20         61  
  20         491  
27 20     20   9992 use CGI::Cookie;
  20         104853  
  20         1126  
28 20     20   10272 use POSIX qw(mktime);
  20         136403  
  20         609  
29 20     20   28101 use XAO::Cache;
  20         72  
  20         595  
30 20     20   138 use XAO::Errors qw(XAO::DO::Web::Config);
  20         70  
  20         150  
31 20     20   8696 use XAO::Objects;
  20         55  
  20         499  
32 20     20   100 use XAO::Utils;
  20         48  
  20         1434  
33              
34 20     20   139 use base XAO::Objects->load(objname => 'Embeddable');
  20         93  
  20         140  
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         65 my $defaults;
72              
73 48         154 my $base_config=$self->base_config;
74 48 50 33     448 if($base_config && $base_config->can('get')) {
75 48 100       1003 if(my $cookie_config=$base_config->get('/xao/cookie')) {
76 25 100       1760 foreach my $cf ($cookie_config->{'common'}, (exists $cookie_config->{$cookie_name} ? ($cookie_config->{$cookie_name}) : ())) {
77 32 50       75 $cf || next;
78              
79 32         77 foreach my $n (keys %$cf) {
80 106 100       233 my $nn=$n =~ /^-/ ? $n : '-'.lc($n);
81 106         229 $defaults->{$nn}=$cf->{$n};
82             }
83             }
84             }
85             }
86              
87 48         1011 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 4283 my $self=shift;
153 41 100       153 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       429 if(!ref($cookie)) {
159 4         44 eprint "Passing baked cookies to ".$self."::add_cookie() is STRONGLY DEPRECATED!";
160              
161 20     20   27750 use CGI::Util qw();
  20         76  
  20         44985  
162              
163             # Expecting something like:
164             # foo=bar; path=/; expires=Wed, 16-Dec-2015 03:32:33 GMT
165             #
166 4         226 my ($nv,@params)=split(/\s*;\s*/,$cookie);
167              
168 4         12 my ($name,$value);
169 4 50       31 if($nv=~/^\s*(.+)\s*=\s*(.*?)\s*$/) {
170 4         13 $name=CGI::Util::unescape($1);
171 4         81 $value=[map { CGI::Util::unescape($_) } split(/&/,$2)];
  4         9  
172 4 50       66 $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         16 my %chash=(
180             -name => $name,
181             -value => $value,
182             );
183              
184 4         9 foreach my $p (@params) {
185 8 50       55 $p=~/^\s*(.+)\s*=\s*(.*?)\s*$/ || next;
186 8         24 my ($pn,$pv)=(lc($1),$2);
187 8 50       33 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         10 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       103 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         112 my $defaults=$self->_cookie_config($cookie->{'-name'});
207 41 100       133 $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     461 if($cookie->{'-sscompat'} && $cookie->{'-samesite'}) {
214             $self->add_cookie(merge_refs($cookie, {
215 5         27 -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     178 if($cookie->{'-domain'} && ref($cookie->{'-domain'})) {
224 2         6 my $dlist=$cookie->{'-domain'};
225 2         5 foreach my $domain (@$dlist) {
226 4         18 $self->add_cookie(merge_refs($cookie,{
227             -domain => $domain,
228             }));
229             }
230 2         9 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       94 if($self->{'cookies'}) {
236 27         95 my $cnew=CGI::Cookie->new($cookie);
237              
238 27         7082 for(my $i=0; $i!=@{$self->{'cookies'}}; $i++) {
  114         1341  
239 96         154 my $c=$self->{'cookies'}->[$i];
240              
241 96 50 33     375 next unless ref($c) && ref($c) eq 'HASH';
242              
243 96         230 my $cstored=CGI::Cookie->new($c);
244              
245 96         25664 my $dnew=$cnew->domain();
246 96         575 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     552 $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         226 $self->{'cookies'}->[$i]=$cookie;
258              
259 9         53 return $cookie;
260             }
261             }
262              
263 30         46 push(@{$self->{'cookies'}},$cookie);
  30         83  
264              
265 30         100 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 2173 my ($self,$newcgi)=@_;
293              
294 352 100       1294 return $self->{'cgi'} unless $newcgi;
295              
296 140 50       421 if($self->{'special_access'}) {
297 140         266 $self->{'cgi'}=$newcgi;
298 140         327 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 15688 my $self=shift;
316 270         672 delete $self->{'cgi'};
317 270         884 delete $self->{'clipboard'};
318 270         472 delete $self->{'cookies'};
319 270         502 delete $self->{'header_args'};
320 270         418 delete $self->{'force_byte_output'};
321 270         424 delete $self->{'header_printed'};
322 270         655 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 6565 my $self=shift;
336 1105 100       2966 $self->{'clipboard'}=XAO::SimpleHash->new() unless $self->{'clipboard'};
337 1105         3989 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 421 my $self=shift;
350              
351 23         37 my @baked;
352 23         31 foreach my $c (@{$self->{'cookies'}}) {
  23         74  
353 30 50 33     4583 if(ref($c) && ref($c) eq 'HASH') {
354 30         46 push @baked,CGI::Cookie->new(%{$c});
  30         142  
355             }
356             else {
357 0         0 push @baked,$c;
358             }
359             }
360              
361 23         2848 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 1190 my $self=shift;
374 132         382 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 3652 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 1624 my $self=shift;
411 140         423 $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 3932 my ($self,$value)=@_;
429 119 100       285 if(defined $value) {
430 33         59 $self->{'force_byte_output'}=$value;
431             }
432 119         389 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 66 my $self=shift;
457              
458 11 50       31 return undef if $self->{'header_printed'};
459              
460 11 50       27 $self->header_args(@_) if @_;
461              
462 11         20 $self->{'header_printed'}=1;
463              
464 11         24 return $self->cgi->header($self->header_array());
465             }
466              
467             ###############################################################################
468              
469             sub header_array ($) {
470 11     11 0 14 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     31 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 2081 my $self=shift;
534 315         897 my $args=get_args(\@_);
535              
536 315         4192 @{$self->{'header_args'}}{map { $self->header_normalize($_) } keys %{$args}}=values %{$args};
  315         1009  
  394         823  
  315         687  
  315         879  
537              
538 315         659 my @todrop=grep { ! defined $self->{'header_args'}->{$_} } keys %{$self->{'header_args'}};
  779         1841  
  315         761  
539 315 100       940 delete @{$self->{'header_args'}}{@todrop} if @todrop;
  1         5  
540              
541 315         1111 return $self->{'header_args'};
542             }
543              
544             ###############################################################################
545              
546             sub header_normalize ($$) {
547 395     395 0 725 my ($self,$header)=@_;
548              
549 395         912 $header=lc($header);
550 395         1399 $header=~s/-/_/g;
551 395         1374 $header=~s/^_+//;
552              
553 395         1112 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 47 my $self=shift;
568              
569 1         2 delete @{$self->{'header_args'}}{map { $self->header_normalize($_) } @_};
  1         4  
  1         7  
570              
571 1         3 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 13671 my ($self,$name,$original)=@_;
591              
592 54 50 33     260 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         92 my $value;
601              
602 54 100       109 if(!$original) {
603 40 100       70 foreach my $c (reverse @{$self->{'cookies'} || []}) {
  40         135  
604 105         793 my $cookie=CGI::Cookie->new($c);
605              
606 105 100       28019 if($cookie->name() eq $name) {
607 26         200 my $value=$cookie->value;
608              
609 26         165 my $expires_text=$cookie->expires;
610              
611 26 50       236 if($expires_text =~ /(\d{2})\W+([a-z]{3})\W+(\d{4})\W+(\d{2})\W+(\d{2})\W+(\d{2})/i) {
612 26         88 my $midx=index('janfebmaraprmayjunjulaugsepoctnovdec',lc($2));
613 26 50       52 if($midx>=0) {
614 26         45 $midx/=3;
615 26         179 local($ENV{'TZ'})='UTC';
616 26         205 my $expires=mktime($6,$5,$4,$1,$midx,$3-1900);
617 26 100       122 if($expires <= time) {
618 4         17 $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         161 return $value;
630             }
631             }
632             }
633              
634 28         91 my $cgi=$self->cgi;
635              
636 28 50       74 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         58 $value=$self->cgi->cookie($name);
650 28 100       30506 if(!defined $value) {
651 7         18 my $defaults=$self->_cookie_config($name);
652 7 100       23 if($defaults->{'-sscompat'}) {
653 2         7 $value=$self->cgi->cookie($name.'-sscompat');
654             }
655             }
656              
657 28         6904 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 1881 my $proto=shift;
670 38   33     558 bless {},ref($proto) || $proto;
671             }
672              
673             ###############################################################################
674             1;
675             __END__