File Coverage

blib/lib/Net/MitDK.pm
Criterion Covered Total %
statement 39 328 11.8
branch 0 160 0.0
condition 0 36 0.0
subroutine 13 55 23.6
pod 0 23 0.0
total 52 602 8.6


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