File Coverage

blib/lib/Net/MitDK.pm
Criterion Covered Total %
statement 39 320 12.1
branch 0 154 0.0
condition 0 35 0.0
subroutine 13 56 23.2
pod 0 23 0.0
total 52 588 8.8


line stmt bran cond sub pod time code
1              
2             use strict;
3 1     1   577 use warnings;
  1         1  
  1         21  
4 1     1   4 our $VERSION = '0.02';
  1         1  
  1         30  
5             use Encode qw(encode decode);
6 1     1   381 use DateTime;
  1         11237  
  1         55  
7 1     1   736 use MIME::Entity;
  1         394884  
  1         41  
8 1     1   663 use MIME::Base64;
  1         68757  
  1         30  
9 1     1   6 use IO::Lambda qw(:all);
  1         2  
  1         40  
10 1     1   554 use IO::Lambda::HTTP::Client;
  1         9855  
  1         260  
11 1     1   649 use IO::Lambda::HTTP::UserAgent;
  1         35194  
  1         60  
12 1     1   358 use HTTP::Request::Common;
  1         8638  
  1         27  
13 1     1   380 use JSON::XS qw(encode_json decode_json);
  1         2833  
  1         58  
14 1     1   470  
  1         2478  
  1         2622  
15             {
16             my ( $class, %opt ) = @_;
17             my $self = bless {
18 0     0 0   profile => 'default',
19 0           ua => IO::Lambda::HTTP::UserAgent->new,
20             root => 'https://gateway.mit.dk/view/client',
21             mgr => Net::MitDK::ProfileManager->new,
22             session => {},
23             config => {},
24             %opt,
25             }, $class;
26              
27             $self->mgr->homepath( $opt{homepath}) if defined $opt{homepath};
28              
29 0 0         if ( defined $self->{profile}) {
30             my ($config, $error) = $self->mgr->load( $self->profile );
31 0 0         return (undef, $error) unless $config;
32 0           $self->{config} = $config;
33 0 0         }
34 0            
35             return $self;
36             }
37 0            
38              
39             {
40 0     0 0   my $self = shift;
41             if ( $self->mgr->refresh_needed( $self->profile ) ) {
42             my ($config, $error) = $self->mgr->load( $self->profile );
43             return (undef, $error) unless $config;
44 0     0 0   $self->{config} = $config;
45 0 0         }
46 0           return 1;
47 0 0         }
48 0            
49              
50 0           {
51             return $_[0]->{profile} unless $#_;
52             my ( $self, $profile ) = @_;
53 0     0 0   return undef if $profile eq $self->{profile};
54 0     0 0    
55 0     0 0   my ($config, $error) = $self->mgr->load( $profile );
56 0     0 0   return $error unless $config;
57              
58             $self->{session} = {};
59             $self->{config} = $config;
60 0 0   0 0   $self->{profile} = $profile;
61 0            
62 0 0         return undef;
63             }
64 0            
65 0 0         {
66             my ($self, $method, $uri, $content, $options) = @_;
67 0            
68 0           my ($ok, $error) = $self->refresh_config;
69 0           return lambda { undef, $error } unless $ok;
70              
71 0           my %extra;
72             if ($method eq 'get' ) {
73             $method = \&HTTP::Request::Common::GET;
74             $options = $content;
75             } else {
76 0     0 0   $method = \&HTTP::Request::Common::POST;
77             $extra{content} = encode_json($content);
78 0           $extra{'content-type'} = 'application/json';
79 0 0   0     }
  0            
80             $options //= {};
81 0            
82 0 0         lambda {
83 0           my $token = $self->config->{token};
84 0           context $self->ua->request( $method->(
85             $self->root . '/' . $uri,
86 0           ngdptoken => $token->{ngdp}->{access_token},
87 0           mitdktoken => $token->{dpp}->{access_token},
88 0           %extra
89             ));
90 0   0       tail {
91             my $response = shift;
92             return (undef, $response) unless ref $response;
93 0     0      
94             my $json;
95             unless ($response->is_success) {
96             if ( $response->header('content-type') eq 'application/json') {
97             eval { $json = decode_json($response->content) };
98 0           goto PLAIN if $@;
99             goto PLAIN if grep { ! exists $json->{$_} } qw(code message);
100             my $err = "$json->{code}: $json->{message}";
101 0           $err .= "(" . join(' ', @{$_->{fieldError}}) . ')'
102 0 0         if $json->{fieldError} && ref($_->{fieldError}) eq 'ARRAY';
103             return undef, $err;
104 0           } else {
105 0 0         PLAIN:
106 0 0         return undef, $response->content
107 0           }
  0            
108 0 0         }
109 0 0          
  0            
110 0           return $response if $options->{raw};
111 0            
112 0 0 0       return undef, 'invalid content'
113 0           unless $response->header('Content-Type') eq 'application/json';
114              
115 0           eval { $json = decode_json($response->content) };
116             return undef, "invalid response ($@)"
117             unless $json;
118              
119             if ( $json->{errorMessages} && ref($json->{errorMessages}) eq 'ARRAY') {
120 0 0         $error = join("\n", map {
121             my $err = "$_->{code}: $_->{message}";
122 0 0         $err .= "(" . join(' ', @{$_->{fieldError}}) . ')'
123             if $_->{fieldError} && ref($_->{fieldError}) eq 'ARRAY';
124             $err
125 0           } @{ $json->{errorMessages} });
  0            
126 0 0         return undef, $error if length $error;
127             }
128              
129 0 0 0       return $json;
130             }};
131 0           }
132 0            
133 0 0 0        
134 0           {
135 0           my ($self, $json) = @_;
  0            
136 0 0         return $self->authorization_refresh( $json->{refresh_token}, $json->{ngdp}->{refresh_token});
137             }
138              
139 0           {
140 0           my ($self) = @_;
  0            
141             my $token = $self->config->{token};
142             return lambda {undef, "expired"}
143 0     0 0   if $token->{dpp}->{expires_on} < time || $token->{ngdp}->{expires_on} < time;
144 0     0 0   return $self->authorization_refresh( $token->{dpp}->{refresh_token}, $token->{ngdp}->{refresh_token});
145             }
146              
147             {
148 0     0 0   my $self = shift;
149 0           return $self->mgr->save( $self->profile, $self->{config});
150             }
151              
152             {
153             my ($self, $dpp, $ngdp) = @_;
154 0     0 0   return lambda {
155 0           context $self->post('authorization/refresh?client_id=view-client-id-mobile-prod-1-id' => {
156 0     0     dppRefreshToken => $dpp,
157 0 0 0       ngdpRefreshToken => $ngdp,
158 0           });
159             tail {
160             my ($json, $error) = @_;
161             return $json, $error unless $json;
162             return undef, "bad response:".encode_json($json) unless exists $json->{dpp} and exists $json->{ngdp};
163 0     0 0    
164 0           $self->{config}->{token} = $json;
165             return $self->update_config;
166             }}
167             }
168              
169 0     0 0   {
170             my $self = shift;
171 0     0      
172             return lambda {
173             return $self->{session}->{mailboxes} if $self->{session}->{mailboxes};
174              
175             context $self->get('mailboxes');
176 0           tail {
177 0 0         my ( $json, $error ) = @_;
178 0 0 0       return ($json, $error) unless $json;
179              
180 0           ($json) = grep { $_->{dataSource} eq 'DP_PUBLIC' } @{$json->{groupedMailboxes}->[0]->{mailboxes}};
181 0           return (undef, "mailboxes: bad structure") unless $json;
182 0           return $self->{session}->{mailboxes} = $json;
183 0           }};
184             }
185              
186             {
187 0     0 0   my $self = shift;
188              
189             return lambda {
190 0 0   0     return $self->{session}->{folders} if $self->{session}->{folders};
191              
192 0           context $self-> mailboxes;
193             tail {
194 0           return @_ unless $_[0];
195 0 0          
196             context $self->post('folders/query' => {
197 0           "mailboxes" => { DP_PUBLIC => $self->{session}->{mailboxes}->{id} }
  0            
  0            
198 0 0         });
199 0           tail {
200 0           my ( $json, $errors ) = @_;
  0            
201             return ($json, $errors) unless $json;
202             my %folders;
203             while ( my ( $k, $v ) = each %{$json->{folders}}) {
204             $folders{$k} = $v->[0]->{id};
205 0     0 0   }
206             return (undef, "folders: bad structure") unless keys %folders;
207             return $self->{session}->{folders} = \%folders;
208 0 0   0     }}};
209             }
210 0            
211              
212 0 0         {
213             my ( $self, $offset, $limit ) = @_;
214             return lambda {
215             context $self-> folders;
216 0           tail {
217             return @_ unless $_[0];
218 0            
219 0 0         my $session = $self->{session};
220 0           context $self->post('messages/query' => {
221 0           size => $limit,
  0            
222 0           sortFields => ["receivedDateTime:DESC"],
223             folders => [{
224 0 0         dataSource => 'DP_PUBLIC',
225 0           foldersId => [$session->{folders}->{INBOX}],
226 0           mailboxId => $session->{mailboxes}->{id},
  0            
  0            
227             startIndex => $offset,
228             }],
229             });
230             tail {
231             @_
232 0     0 0   }}};
233             }
234 0     0      
235             {
236 0 0         my $self = shift;
237              
238 0           my $offset = 0;
239             my $limit = 100;
240              
241             my @ret;
242              
243             return lambda {
244             context $self->messages($offset, $limit);
245             tail {
246 0           my ($json, $error) = @_;
247             return ($json, $error) unless $json;
248              
249             push @ret, @{ $json->{results} };
250             return \@ret if @{ $json->{results} } < $limit;
251 0            
  0            
  0            
  0            
252             $offset += $limit;
253             context $self->messages($offset, $limit);
254             again;
255             }};
256 0     0 0   }
257              
258 0           {
259 0           my ( $self, $message, $document, $file ) = @_;
260             return $self->get('DP_PUBLIC/' .
261 0           "mailboxes/$self->{session}->{mailboxes}->{id}/" .
262             "messages/$message->{id}/" .
263             "documents/$message->{documents}->[$document]->{id}/" .
264 0     0     "files/$message->{documents}->[$document]->{files}->[$file]->{id}/".
265             "content",
266 0            
267 0 0         {raw => 1},
268             );
269 0           }
  0            
270 0 0          
  0            
271             {
272 0           my ($self, $message) = @_;
273 0           my @ret;
274 0            
275 0           return lambda {
  0            
276             my @files;
277             my ( $ndoc, $nfile ) = (0,0);
278             for my $doc ( @{ $message->{documents} } ) {
279             for my $file ( @{ $doc->{files} } ) {
280 0     0 0   push @files, [ $ndoc, $nfile++ ];
281 0           }
282             $nfile = 0;
283             $ndoc++;
284             }
285             return [] unless @files;
286              
287             ($ndoc, $nfile) = @{ shift @files };
288             context $self-> fetch_file($message, $ndoc, $nfile);
289             tail {
290             my ($resp, $error) = @_;
291             return ($resp, $error) unless defined $resp;
292              
293             push @ret, [ $ndoc, $nfile, $resp->content ];
294 0     0 0    
295 0           return \@ret unless @files;
296             ($ndoc, $nfile) = @{ shift @files };
297              
298 0     0     context $self-> fetch_file($message, $ndoc, $nfile);
299 0           again;
300 0           }};
  0            
301 0           }
  0            
302 0            
303             {
304 0           my ($enc, $text) = @_;
305 0           utf8::downgrade($text, 'fail silently please');
306             return (utf8::is_utf8($text) || $text =~ /[\x80-\xff]/) ? encode($enc, $text) : $text;
307 0 0         }
308              
309 0           {
  0            
310 0           my ( $self, $msg, $attachments ) = @_;
311              
312 0           my $sender = $msg->{sender}->{label};
313 0 0          
314             my $received = $msg->{receivedDateTime} // '';
315 0           my $date;
316             if ( $received =~ /^(\d{4})-(\d{2})-(\d{2})T(\d\d):(\d\d):(\d\d)/) {
317 0 0         $date = DateTime->new(
318 0           year => $1,
  0            
319             month => $2,
320 0           day => $3,
321 0           hour => $4,
322 0           minute => $5,
  0            
323             second => $6,
324             );
325             } else {
326             $date = DateTime->now;
327 0     0 0   }
328 0           $received = $date->strftime('%a, %d %b %Y %H:%M:%S %z');
329 0 0 0        
330             my $mail = MIME::Entity->build(
331             From => ( safe_encode('MIME-Q', $sender) . ' <noreply@mit.dk>' ) ,
332             To => ( safe_encode('MIME-Q', $self->{session}->{mailboxes}->{ownerName}) . ' <' . ( $ENV{USER} // 'you' ) . '@localhost>' ),
333             Subject => safe_encode('MIME-Header', $msg->{label}),
334 0     0 0   Data => encode('utf-8', "Mail from $sender"),
335             Date => $received,
336 0           Charset => 'utf-8',
337             Encoding => 'quoted-printable',
338 0   0       'X-Net-MitDK' => "v/$VERSION",
339 0           );
340 0 0          
341 0           for ( @$attachments ) {
342             my ( $ndoc, $nfile, $body ) = @$_;
343             my $file = $msg->{documents}->[$ndoc]->{files}->[$nfile];
344             my $fn = $file->{filename};
345             Encode::_utf8_off($body);
346              
347             my $entity = $mail->attach(
348             Type => $file->{encodingFormat},
349             Encoding => 'base64',
350 0           Data => $body,
351             Filename => $fn,
352 0           );
353              
354             # XXX hack filename for utf8
355             next unless $fn =~ m/[^\x00-\x80]/;
356             $fn = Encode::encode('MIME-B', $fn);
357 0   0       for ( 'Content-disposition', 'Content-type') {
358             my $v = $entity->head->get($_);
359             $v =~ s/name="(.*)"/name="$fn"/;
360             $entity->head->replace($_, $v);
361             }
362             }
363              
364             return
365 0           'From noreply@localhost ' .
366 0           $date->strftime('%a %b %d %H:%M:%S %Y') . "\n" .
367 0           $mail->stringify
368 0           ;
369 0           }
370              
371             package
372             Net::MitDK::ProfileManager;
373 0            
374             use Fcntl ':seek', ':flock';
375             use JSON::XS qw(encode_json decode_json);
376              
377             {
378             my $self = bless {
379 0 0         timestamps => {},
380 0           homepath => undef,
381 0           readonly => 0,
382 0           }, shift;
383 0           return $self;
384 0           }
385              
386             {
387              
388             if ( exists $ENV{HOME}) {
389 0           return $ENV{HOME};
390             } elsif ( $^O =~ /win/i && exists $ENV{USERPROFILE}) {
391             return $ENV{USERPROFILE};
392             } elsif ( $^O =~ /win/i && exists $ENV{WINDIR}) {
393             return $ENV{WINDIR};
394             } else {
395             return '.';
396             }
397             }
398 1     1   8  
  1         2  
  1         143  
399 1     1   6  
  1         1  
  1         1048  
400             {
401             $#_ ? $_[0]->{homepath} = $_[1] : ($_[0]->{homepath} // _homepath . '/.mitdk')
402             }
403 0     0      
404             {
405             my $self = shift;
406             my $home = $self->homepath;
407              
408 0           return unless -d $home;
409             my @list;
410             for my $profile ( <$home/*.profile> ) {
411             $profile =~ m[\/([^\/]+)\.profile] or next;
412             push @list, $1;
413             }
414 0 0 0 0     return @list;
    0 0        
    0          
415 0           }
416              
417 0           {
418             my ($self, $profile, %opt) = @_;
419 0           my $file = $self->homepath . "/$profile.profile";
420              
421 0           if ( -f $file ) {
422             return 2 if $opt{ok_if_exists};
423             return (undef, "Profile exists already");
424             }
425 0 0   0      
426             return $self->save($profile, $opt{payload} // {} );
427             }
428              
429 0 0 0 0     {
430             my $f = shift;
431             return 1 if flock( $f, LOCK_NB | LOCK_EX);
432             sleep(1);
433             return 1 if flock( $f, LOCK_NB | LOCK_EX);
434 0     0     sleep(1);
435 0           return flock( $f, LOCK_NB | LOCK_EX);
436             }
437 0 0          
438 0           {
439 0           my ($self, $profile ) = @_;
440 0 0         my $file = $self->homepath . "/$profile.profile";
441 0            
442             return (undef, "No such profile") unless -f $file;
443 0           local $/;
444             open my $f, "<", $file or return (0, "Cannot open $file:$!");
445             return (undef, "Cannot acquire lock on $file") unless lock($f);
446              
447             my $r = <$f>;
448 0     0     close $f;
449 0            
450             my $json;
451 0 0         eval { $json = decode_json($r) };
452 0 0         return (undef, "Corrupted profile $file: $@") unless $json;
453 0            
454             $self->{timestamps}->{$profile} = time;
455              
456 0   0       return $json;
457             }
458              
459             {
460             my ($self, $profile, $hash) = @_;
461 0     0      
462 0 0         return (undef, "$profile is readonly") if $self->readonly;
463 0            
464 0 0         my $home = $self->homepath;
465 0           unless ( -d $home ) {
466 0           mkdir $home or return (undef, "Cannot create $home: $!");
467             return (undef, "cannot chmod 0750 $home:$!") unless chmod 0750, $home;
468             my ($name,$passwd,$uid,$gid) = getpwnam('nobody');
469             return (undef, "no user `nobody`") unless defined $name;
470             return (undef, "cannot chown user:$name $home:$!") unless chown $>, $gid, $home;
471 0     0     }
472 0            
473             my $json;
474 0 0         my $encoder = JSON::XS->new->ascii->pretty;
475 0           eval { $json = $encoder->encode($hash) };
476 0 0         return (undef, "Cannot serialize profile: $!") if $@;
477 0 0          
478             my $file = "$home/$profile.profile";
479 0           my $f;
480 0           if ( -f $file ) {
481             open $f, "+<", $file or return (undef, "Cannot create $file:$!");
482 0           return (undef, "Cannot acquire lock on $file") unless lock($f);
483 0           seek $f, 0, SEEK_SET;
  0            
484 0 0         truncate $f, 0 or return (undef, "Cannot save $file:$!");
485             } else {
486 0           open $f, ">", $file or return (undef, "Cannot create $file:$!");
487             }
488 0           print $f $json or return (undef, "Cannot save $file:$!");
489             close $f or return (undef, "Cannot save $file:$!");
490              
491             if ( $^O !~ /win32/i) {
492             return (undef, "cannot chmod 0640 $file:$!") unless chmod 0640, $file;
493 0     0     my ($name,$passwd,$uid,$gid) = getpwnam('nobody');
494             return (undef, "no user `nobody`") unless defined $name;
495 0 0         return (undef, "cannot chown user:$name $file:$!") unless chown $>, $gid, $file;
496             }
497 0            
498 0 0         $self->{timestamps}->{$profile} = time;
499 0 0          
500 0 0         return 1;
501 0           }
502 0 0          
503 0 0         {
504             my ($self, $profile) = @_;
505             unlink $self->homepath . "/$profile.profile" or return (undef, "Cannot remove $profile:$!");
506 0           return 1;
507 0           }
508 0            
  0            
509 0 0         {
510             my ( $self, $profile ) = @_;
511 0           return 0 unless exists $self->{timestamps}->{$profile};
512 0            
513 0 0         my $file = $self->homepath . "/$profile.profile";
514 0 0         my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks) = stat($file);
515 0 0         return 0 unless defined $mtime;
516 0            
517 0 0         return $mtime > $self->{timestamps}->{$profile};
518             }
519 0 0          
520             1;
521 0 0          
522 0 0         =pod
523              
524 0 0         =head1 NAME
525 0 0          
526 0           Net::MitDK - perl API for http://mit.dk/
527 0 0          
528 0 0         =head1 DESCRIPTION
529              
530             Read-only interface for MitDK. See README for more info.
531 0            
532             =head1 AUTHOR
533 0            
534             Dmitry Karasik <dmitry@karasik.eu.org>
535              
536             =cut