File Coverage

blib/lib/Net/DAVTalk.pm
Criterion Covered Total %
statement 39 222 17.5
branch 0 90 0.0
condition 0 37 0.0
subroutine 13 29 44.8
pod 16 16 100.0
total 68 394 17.2


line stmt bran cond sub pod time code
1              
2             use strict;
3 1     1   88269  
  1         2  
  1         27  
4             use Carp;
5 1     1   5 use DateTime::Format::ISO8601;
  1         2  
  1         47  
6 1     1   519 use DateTime::TimeZone;
  1         858702  
  1         56  
7 1     1   12 use HTTP::Tiny;
  1         2  
  1         21  
8 1     1   732 use JSON;
  1         38168  
  1         39  
9 1     1   668 use Tie::DataUUID qw{$uuid};
  1         9026  
  1         8  
10 1     1   545 use XML::Spice;
  1         2431  
  1         7  
11 1     1   530 use Net::DAVTalk::XMLParser;
  1         1233  
  1         5  
12 1     1   413 use MIME::Base64 qw(encode_base64);
  1         3  
  1         53  
13 1     1   474 use Encode qw(encode);
  1         539  
  1         55  
14 1     1   5 use URI::Escape qw(uri_escape uri_unescape);
  1         2  
  1         60  
15 1     1   414 use URI;
  1         1243  
  1         57  
16 1     1   615  
  1         2335  
  1         2669  
17             =head1 NAME
18              
19             Net::DAVTalk - Interface to talk to DAV servers
20              
21             =head1 VERSION
22              
23             Version 0.22
24              
25             =cut
26              
27             our $VERSION = '0.22';
28              
29             =head1 SYNOPSIS
30              
31             Net::DAVTalk is was originally designed as a service module for Net::CalDAVTalk
32             and Net::DAVTalk, abstracting the process of connecting to a DAV server and
33             parsing the XML responses.
34              
35             Example:
36              
37             use Net::DAVTalk;
38             use XML::Spice;
39              
40             my $davtalk = Net::DAVTalk->new(
41             url => "https://dav.example.com/",
42             user => "foo\@example.com",
43             password => "letmein",
44             headers => { Cookie => "123", Referer => "456" },
45             );
46              
47             $davtalk->Request(
48             'MKCALENDAR',
49             "$calendarId/",
50             x('C:mkcalendar', $Self->NS(),
51             x('D:set',
52             x('D:prop', @Properties),
53             ),
54             ),
55             );
56              
57             $davtalk->Request(
58             'DELETE',
59             "$calendarId/",
60             );
61              
62             =head1 SUBROUTINES/METHODS
63              
64             =head2 $class->new(%Options)
65              
66             Options:
67              
68             url: either full https?:// url, or relative base path on the
69             server to the DAV endpoint
70              
71             host, scheme and port: alternative to using full URL.
72             If URL doesn't start with https?:// then these will be used to
73             construct the endpoint URI.
74              
75             expandurl and wellknown: if these are set, then the wellknown
76             name (caldav and carddav are both defined) will be used to
77             resolve /.well-known/$wellknown to find the current-user-principal
78             URI, and then THAT will be resovlved to find the $wellknown-home-set
79             URI, which will be used as the URL for all further actions on
80             this object.
81              
82             user and password: if these are set, perform basic authentication.
83             user and access_token: if these are set, perform Bearer (OAUTH2)
84             authentication.
85              
86             headers: a hashref of additional headers to add to every request
87              
88             =cut
89              
90             # General methods
91              
92             my ($Class, %Params) = @_;
93              
94 0     0 1   unless ($Params{url}) {
95             confess "URL not supplied";
96 0 0         }
97 0            
98             # Assume url points to xyz-home-set, otherwise expand the url
99             if (delete $Params{expandurl}) {
100             # Locating Services for CalDAV and CardDAV (RFC6764)
101 0 0         my $PrincipalURL = $Class->GetCurrentUserPrincipal(%Params);
102             $Params{principal} = $PrincipalURL;
103 0            
104 0           my $HomeSet = $Class->GetHomeSet(
105             %Params,
106 0           url => $PrincipalURL,
107             );
108              
109             $Params{url} = $HomeSet;
110             }
111 0            
112             my $Self = bless \%Params, ref($Class) || $Class;
113             $Self->SetURL($Params{url});
114 0   0       $Self->SetPrincipalURL($Params{principal});
115 0           $Self->ns(D => 'DAV:');
116 0            
117 0           return $Self;
118             }
119 0            
120             =head2 my $ua = $Self->ua();
121             =head2 $Self->ua($setua);
122              
123             Get or set the useragent (HTTP::Tiny or compatible) that will be used to make
124             the requests:
125              
126             e.g.
127              
128             my $ua = $Self->ua();
129              
130             $Self->ua(HTTP::Tiny->new(agent => "MyAgent/1.0", timeout => 5));
131              
132             =cut
133              
134             my $Self = shift;
135             if (@_) {
136             $Self->{ua} = shift;
137 0     0 1   }
138 0 0         else {
139 0           $Self->{ua} ||= HTTP::Tiny->new(
140             agent => "Net-DAVTalk/$VERSION",
141             );
142 0   0       }
143             return $Self->{ua};
144             }
145              
146 0           =head2 $Self->SetURL($url)
147              
148             Change the endpoint URL for an existing connection.
149              
150             =cut
151              
152             my ($Self, $URL) = @_;
153              
154             $URL =~ s{/$}{}; # remove any trailing slash
155              
156 0     0 1   if ($URL =~ m{^https?://}) {
157             my ($HTTPS, $Hostname, $Port, $BasePath)
158 0           = $URL =~ m{^http(s)?://([^/:]+)(?::(\d+))?(.*)?};
159              
160 0 0         unless ($Hostname) {
161 0           confess "Invalid hostname in '$URL'";
162             }
163              
164 0 0         $Self->{scheme} = $HTTPS ? 'https' : 'http';
165 0           $Self->{host} = $Hostname;
166             $Self->{port} = ($Port || ($HTTPS ? 443 : 80));
167             $Self->{basepath} = $BasePath;
168 0 0         }
169 0           else {
170 0   0       $Self->{basepath} = $URL;
171 0           }
172              
173             $Self->{url} = "$Self->{scheme}://$Self->{host}:$Self->{port}$Self->{basepath}";
174 0            
175             return $Self->{url};
176             }
177 0            
178             =head2 $Self->SetPrincipalURL($url)
179 0            
180             Set the URL to the DAV Principal
181              
182             =cut
183              
184             my ($Self, $PrincipalURL) = @_;
185              
186             return $Self->{principal} = $PrincipalURL;
187             }
188              
189 0     0 1   =head2 $Self->fullpath($shortpath)
190              
191 0           Convert from a relative path to a full path:
192              
193             e.g
194             my $path = $Dav->fullpath('Default');
195             ## /dav/calendars/user/foo/Default
196              
197             NOTE: a you can pass a non-relative full path (leading /)
198             to this function and it will be returned unchanged.
199              
200             =cut
201              
202             my $Self = shift;
203             my $path = shift;
204             my $basepath = $Self->{basepath};
205             return $path if $path =~ m{^/};
206             return "$basepath/$path";
207             }
208 0     0 1    
209 0           =head2 $Self->shortpath($fullpath)
210 0            
211 0 0         Convert from a full path to a relative path
212 0            
213             e.g
214             my $path = $Dav->fullpath('/dav/calendars/user/foo/Default');
215             ## Default
216              
217             NOTE: if the full path is outside the basepath of the object, it
218             will be unchanged.
219              
220             my $path = $Dav->fullpath('/dav/calendars/user/bar/Default');
221             ## /dav/calendars/user/bar/Default
222              
223             =cut
224              
225             my $Self = shift;
226             my $origpath = shift;
227             my $basepath = $Self->{basepath};
228             my $path = $origpath;
229             $path =~ s{^$basepath/?}{};
230             return ($path eq '' ? $origpath : $path);
231             }
232 0     0 1    
233 0           =head2 $Self->Request($method, $path, $content, %headers)
234 0            
235 0           The whole point of the module! Perform a DAV request against the
236 0           endpoint, returning the response as a parsed hash.
237 0 0          
238             method: http method, i.e. GET, PROPFIND, MKCOL, DELETE, etc
239              
240             path: relative to base url. With a leading slash, relative to
241             server root, i.e. "Default/", "/dav/calendars/user/foo/Default".
242              
243             content: if the method takes a body, raw bytes to send
244              
245             headers: additional headers to add to request, i.e (Depth => 1)
246              
247             =cut
248              
249             my ($Self, $Method, $Path, $Content, %Headers) = @_;
250              
251             # setup request {{{
252              
253             $Content = '' unless defined $Content;
254             my $Bytes = encode('UTF-8', $Content);
255              
256             my $ua = $Self->ua();
257 0     0 1    
258             $Headers{'Content-Type'} //= 'application/xml; charset=utf-8';
259              
260             if ($Self->{user}) {
261 0 0         $Headers{'Authorization'} = $Self->auth_header();
262 0           }
263              
264 0           if ($Self->{headers}) {
265             $Headers{$_} = $Self->{headers}->{$_} for ( keys %{ $Self->{headers} } );
266 0   0       }
267              
268 0 0         # XXX - Accept-Encoding for gzip, etc?
269 0            
270             # }}}
271              
272 0 0         # send request {{{
273 0            
  0            
274             my $URI = $Self->request_url($Path);
275              
276             my $Response = $ua->request($Method, $URI, {
277             headers => \%Headers,
278             content => $Bytes,
279             });
280              
281             if ($Response->{status} == '599' and $Response->{content} =~ m/timed out/i) {
282 0           confess "Error with $Method for $URI (504, Gateway Timeout)";
283             }
284 0            
285             my $count = 0;
286             while ($Response->{status} =~ m{^30[1278]} and (++$count < 10)) {
287             my $location = URI->new_abs($Response->{headers}{location}, $URI);
288             if ($ENV{DEBUGDAV}) {
289 0 0 0       warn "******** REDIRECT ($count) $Response->{status} to $location\n";
290 0           }
291              
292             $Response = $ua->request($Method, $location, {
293 0           headers => \%Headers,
294 0   0       content => $Bytes,
295 0           });
296 0 0          
297 0           if ($Response->{status} == '599' and $Response->{content} =~ m/timed out/i) {
298             confess "Error with $Method for $location (504, Gateway Timeout)";
299             }
300 0           }
301              
302             # one is enough
303              
304             my $ResponseContent = $Response->{content} || '';
305 0 0 0        
306 0           if ($ENV{DEBUGDAV}) {
307             warn "<<<<<<<< $Method $URI HTTP/1.1\n$Bytes\n" .
308             ">>>>>>>> $Response->{protocol} $Response->{status} $Response->{reason}\n$ResponseContent\n" .
309             "========\n\n";
310             }
311              
312 0   0       if ($Method eq 'REPORT' && $Response->{status} == 403) {
313             # maybe invalid sync token, need to return that fact
314 0 0         my $Xml = xmlToHash($ResponseContent);
315 0           if (exists $Xml->{"{DAV:}valid-sync-token"}) {
316             return {
317             error => "valid-sync-token",
318             };
319             }
320 0 0 0       }
321              
322 0           unless ($Response->{success}) {
323 0 0         confess("ERROR WITH REQUEST\n" .
324             "<<<<<<<< $Method $URI HTTP/1.1\n$Bytes\n" .
325 0           ">>>>>>>> $Response->{protocol} $Response->{status} $Response->{reason}\n$ResponseContent\n" .
326             "========\n\n");
327             }
328              
329             if ((grep { $Method eq $_ } qw{GET DELETE}) or ($Response->{status} != 207) or (not $ResponseContent)) {
330 0 0         return { content => $ResponseContent };
331 0           }
332              
333             # }}}
334              
335             # parse XML response {{{
336             my $Xml = xmlToHash($ResponseContent);
337 0 0 0        
  0   0        
338 0           # Normalise XML
339              
340             if (exists($Xml->{"{DAV:}response"})) {
341             if (ref($Xml->{"{DAV:}response"}) ne 'ARRAY') {
342             $Xml->{"{DAV:}response"} = [ $Xml->{"{DAV:}response"} ];
343             }
344 0            
345             foreach my $Response (@{$Xml->{"{DAV:}response"}}) {
346             if (exists($Response->{"{DAV:}propstat"})) {
347             unless (ref($Response->{"{DAV:}propstat"}) eq 'ARRAY') {
348 0 0         $Response->{"{DAV:}propstat"} = [$Response->{"{DAV:}propstat"}];
349 0 0         }
350 0           }
351             }
352             }
353 0            
  0            
354 0 0         return $Xml;
355 0 0          
356 0           # }}}
357             }
358              
359             =head2 $Self->GetProps($Path, @Props)
360              
361             perform a propfind on a particular path and get the properties back
362 0            
363             =cut
364              
365             my ($Self, $Path, @Props) = @_;
366             my @res = $Self->GetPropsArray($Path, @Props);
367             return wantarray ? map { $_->[0] } @res : $res[0][0];
368             }
369              
370             =head2 $Self->GetPropsArray($Path, @Props)
371              
372             perform a propfind on a particular path and get the properties back
373             as an array of one or more items
374 0     0 1    
375 0           =cut
376 0 0          
  0            
377             my ($Self, $Path, @Props) = @_;
378              
379             # Fetch one or more properties.
380             # Use [ 'prop', 'sub', 'item' ] to dig into result structure
381              
382             my $NS_D = $Self->ns('D');
383              
384             my $Response = $Self->Request(
385             'PROPFIND',
386             $Path,
387 0     0 1   x('D:propfind', $Self->NS(),
388             x('D:prop',
389             map { ref $_ ? x($_->[0]): x($_) } @Props,
390             ),
391             ),
392 0           Depth => 0,
393             );
394              
395             my @Results;
396             foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) {
397             foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) {
398             my $PropData = $Propstat->{"{$NS_D}prop"} || next;
399 0 0         for my $Prop (@Props) {
  0            
400             my @Values = ($PropData);
401              
402             # Array ref means we need to git through structure
403             foreach my $Key (ref $Prop ? @$Prop : $Prop) {
404             my @New;
405 0           foreach my $Result (@Values) {
406 0 0         if ($Key =~ m/:/) {
  0            
407 0 0         my ($N, $P) = split /:/, $Key;
  0            
408 0   0       my $NS = $Self->ns($N);
409 0           $Result = $Result->{"{$NS}$P"};
410 0           } else {
411             $Result = $Result->{$Key};
412             }
413 0 0         if (ref($Result) eq 'ARRAY') {
414 0           push @New, @$Result;
415 0           }
416 0 0         elsif (defined $Result) {
417 0           push @New, $Result;
418 0           }
419 0           }
420             @Values = @New;
421 0           }
422              
423 0 0         push @Results, [ map { $_->{content} } @Values ];
    0          
424 0           }
425             }
426             }
427 0            
428             return wantarray ? @Results : $Results[0];
429             }
430 0            
431             =head2 $Self->GetCurrentUserPrincipal()
432             =head2 $class->GetCurrentUserPrincipal(%Args)
433 0            
  0            
434             Can be called with the same args as new() as a class method, or
435             on an existing object. Either way it will use the .well-known
436             URI to find the path to the current-user-principal.
437              
438 0 0         Returns a string with the path.
439              
440             =cut
441              
442             my ($Class, %Args) = @_;
443              
444             if (ref $Class) {
445             %Args = %{$Class};
446             $Class = ref $Class;
447             }
448              
449             my $OriginalURL = $Args{url} || '';
450             my $Self = $Class->new(%Args);
451             my $NS_D = $Self->ns('D');
452             my @BasePath = split '/', $Self->{basepath};
453 0     0 1    
454             @BasePath = ('', ".well-known/$Args{wellknown}") unless @BasePath;
455 0 0          
456 0           PRINCIPAL: while(1) {
  0            
457 0           $Self->SetURL(join '/', @BasePath);
458              
459             if (my $Principal = $Self->GetProps('', [ 'D:current-user-principal', 'D:href' ])) {
460 0   0       $Self->SetURL(uri_unescape($Principal));
461 0           return $Self->{url};
462 0           }
463 0            
464             pop @BasePath;
465 0 0         last unless @BasePath;
466             }
467 0            
468 0           croak "Error finding current user principal at '$OriginalURL'";
469             }
470 0 0          
471 0           =head2 $Self->GetHomeSet
472 0           =head2 $class->GetHomeSet(%Args)
473              
474             Can be called with the same args as new() as a class method, or
475 0           on an existing object. Either way it assumes that the created
476 0 0         object has a 'url' parameter pointing at the current user principal
477             URL (see GetCurrentUserPrincipal above)
478              
479 0           Returns a string with the path to the home set.
480              
481             =cut
482              
483             my ($Class, %Args) = @_;
484              
485             if (ref $Class) {
486             %Args = %{$Class};
487             $Class = ref $Class;
488             }
489              
490             my $OriginalURL = $Args{url} || '';
491             my $Self = $Class->new(%Args);
492             my $NS_D = $Self->ns('D');
493             my $NS_HS = $Self->ns($Args{homesetns});
494             my $HomeSet = $Args{homeset};
495 0     0 1    
496             if (my $Homeset = $Self->GetProps('', [ "$Args{homesetns}:$HomeSet", 'D:href' ])) {
497 0 0         $Self->SetURL(uri_unescape($Homeset));
498 0           return $Self->{url};
  0            
499 0           }
500              
501             croak "Error finding $HomeSet home set at '$OriginalURL'";
502 0   0       }
503 0            
504 0           =head2 $Self->genuuid()
505 0            
506 0           Helper to generate a uuid string. Returns a UUID, e.g.
507              
508 0 0         my $uuid = $DAVTalk->genuuid(); # 9b9d68af-ad13-46b8-b7ab-30ab70da14ac
509 0            
510 0           =cut
511              
512             my $Self = shift;
513 0           return "$uuid";
514             }
515              
516             =head2 $Self->auth_header()
517              
518             Generate the authentication header to use on requests:
519              
520             e.g:
521              
522             $Headers{'Authorization'} = $Self->auth_header();
523              
524             =cut
525 0     0 1    
526 0           my $Self = shift;
527              
528             if ($Self->{password}) {
529             return 'Basic ' . encode_base64("$Self->{user}:$Self->{password}", '');
530             }
531              
532             if ($Self->{access_token}) {
533             return "Bearer $Self->{access_token}";
534             }
535              
536             croak "Need a method to authenticate user (password or access_token)";
537             }
538              
539             =head2 $Self->request_url()
540 0     0 1    
541             Generate the authentication header to use on requests:
542 0 0          
543 0           e.g:
544              
545             $Headers{'Authorization'} = $Self->auth_header();
546 0 0          
547 0           =cut
548              
549             my $Self = shift;
550 0           my $Path = shift;
551              
552             my $URL = $Self->{url};
553              
554             # If a reference, assume absolute
555             if (ref $Path) {
556             ($URL, $Path) = $$Path =~ m{(^https?://[^/]+)(.*)$};
557             }
558              
559             if ($Path) {
560             $Path = join "/", map { uri_escape $_ } split m{/}, $Path, -1;
561             if ($Path =~ m{^/}) {
562             $URL =~ s{(^https?://[^/]+)(.*)}{$1$Path};
563             }
564 0     0 1   else {
565 0           $URL =~ s{/$}{};
566             $URL .= "/$Path";
567 0           }
568             }
569              
570 0 0         return $URL;
571 0           }
572              
573             =head2 $Self->NS()
574 0 0          
575 0           Returns a hashref of the 'xmlns:shortname' => 'full namespace' items for use in XML::Spice body generation, e.g.
  0            
576 0 0          
577 0           $DAVTalk->Request(
578             'MKCALENDAR',
579             "$calendarId/",
580 0           x('C:mkcalendar', $Self->NS(),
581 0           x('D:set',
582             x('D:prop', @Properties),
583             ),
584             ),
585 0           );
586              
587             # { 'xmlns:C' => 'urn:ietf:params:xml:ns:caldav', 'xmlns:D' => 'DAV:' }
588              
589             =cut
590              
591             my $Self = shift;
592              
593             return {
594             map { ( "xmlns:$_" => $Self->ns($_) ) }
595             $Self->ns(),
596             };
597             }
598              
599              
600             =head2 $Self->ns($key, $value)
601              
602             Get or set namespace aliases, e.g
603              
604             $Self->ns(C => 'urn:ietf:params:xml:ns:caldav');
605             my $NS_C = $Self->ns('C'); # urn:ietf:params:xml:ns:caldav
606              
607 0     0 1   =cut
608              
609             my $Self = shift;
610 0            
  0            
611             # case: keys
612             return keys %{$Self->{ns}} unless @_;
613              
614             my $key = shift;
615             # case read one
616             return $Self->{ns}{$key} unless @_;
617              
618             # case write
619             my $prev = $Self->{ns}{$key};
620             $Self->{ns}{$key} = shift;
621             return $prev;
622             }
623              
624             =head2 function2
625              
626 0     0 1   =cut
627              
628             =head1 AUTHOR
629 0 0          
  0            
630             Bron Gondwana, C<< <brong at cpan.org> >>
631 0            
632             =head1 BUGS
633 0 0          
634             Please report any bugs or feature requests to C<bug-net-davtalk at rt.cpan.org>, or through
635             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Net-DAVTalk>. I will be notified, and then you'll
636 0           automatically be notified of progress on your bug as I make changes.
637 0            
638 0            
639              
640              
641             =head1 SUPPORT
642              
643             You can find documentation for this module with the perldoc command.
644              
645             perldoc Net::DAVTalk
646              
647              
648             You can also look for information at:
649              
650             =over 4
651              
652             =item * RT: CPAN's request tracker (report bugs here)
653              
654             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Net-DAVTalk>
655              
656             =item * AnnoCPAN: Annotated CPAN documentation
657              
658             L<http://annocpan.org/dist/Net-DAVTalk>
659              
660             =item * CPAN Ratings
661              
662             L<http://cpanratings.perl.org/d/Net-DAVTalk>
663              
664             =item * Search CPAN
665              
666             L<http://search.cpan.org/dist/Net-DAVTalk/>
667              
668             =back
669              
670              
671             =head1 ACKNOWLEDGEMENTS
672              
673              
674             =head1 LICENSE AND COPYRIGHT
675              
676             Copyright 2015 FastMail Pty. Ltd.
677              
678             This program is free software; you can redistribute it and/or modify it
679             under the terms of the the Artistic License (2.0). You may obtain a
680             copy of the full license at:
681              
682             L<http://www.perlfoundation.org/artistic_license_2_0>
683              
684             Any use, modification, and distribution of the Standard or Modified
685             Versions is governed by this Artistic License. By using, modifying or
686             distributing the Package, you accept this license. Do not use, modify,
687             or distribute the Package, if you do not accept this license.
688              
689             If your Modified Version has been derived from a Modified Version made
690             by someone other than you, you are nevertheless required to ensure that
691             your Modified Version complies with the requirements of this license.
692              
693             This license does not grant you the right to use any trademark, service
694             mark, tradename, or logo of the Copyright Holder.
695              
696             This license includes the non-exclusive, worldwide, free-of-charge
697             patent license to make, have made, use, offer to sell, sell, import and
698             otherwise transfer the Package with respect to any patent claims
699             licensable by the Copyright Holder that are necessarily infringed by the
700             Package. If you institute patent litigation (including a cross-claim or
701             counterclaim) against any party alleging that the Package constitutes
702             direct or contributory patent infringement, then this Artistic License
703             to you shall terminate on the date that such litigation is filed.
704              
705             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
706             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
707             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
708             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
709             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
710             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
711             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
712             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
713              
714              
715             =cut
716              
717             1; # End of Net::DAVTalk