File Coverage

blib/lib/Net/MitDK.pm
Criterion Covered Total %
statement 39 327 11.9
branch 0 160 0.0
condition 0 34 0.0
subroutine 13 55 23.6
pod 0 23 0.0
total 52 599 8.6


line stmt bran cond sub pod time code
1             package Net::MitDK;
2              
3 1     1   900 use strict;
  1         2  
  1         27  
4 1     1   5 use warnings;
  1         2  
  1         40  
5             our $VERSION = '0.04';
6 1     1   561 use Encode qw(encode decode);
  1         15934  
  1         86  
7 1     1   986 use DateTime;
  1         553444  
  1         53  
8 1     1   880 use MIME::Entity;
  1         92472  
  1         36  
9 1     1   10 use MIME::Base64;
  1         2  
  1         53  
10 1     1   771 use IO::Lambda qw(:all);
  1         12709  
  1         266  
11 1     1   600 use IO::Lambda::HTTP::Client;
  1         51262  
  1         80  
12 1     1   475 use IO::Lambda::HTTP::UserAgent;
  1         11961  
  1         32  
13 1     1   481 use HTTP::Request::Common;
  1         3801  
  1         79  
14 1     1   740 use JSON::XS qw(encode_json decode_json);
  1         3342  
  1         3284  
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             my $mail = MIME::Entity->build(
368             From => ( safe_encode('MIME-Q', $sender) . ' <noreply@mit.dk>' ) ,
369             To => ( safe_encode('MIME-Q', $self->{session}->{mailboxes}->{ownerName}) . ' <' . ( $ENV{USER} // 'you' ) . '@localhost>' ),
370 0   0       Subject => safe_encode('MIME-Header', $msg->{label}),
371             Data => encode('utf-8', "Mail from $sender"),
372             Date => $received,
373             Charset => 'utf-8',
374             Encoding => 'quoted-printable',
375             'X-Net-MitDK' => "v/$VERSION",
376             );
377              
378 0           for ( @$attachments ) {
379 0           my ( $ndoc, $nfile, $body ) = @$_;
380 0           my $file = $msg->{documents}->[$ndoc]->{files}->[$nfile];
381 0           my $fn = $file->{filename};
382 0           Encode::_utf8_off($body);
383              
384             my $entity = $mail->attach(
385             Type => $file->{encodingFormat},
386 0           Encoding => 'base64',
387             Data => $body,
388             Filename => $fn,
389             );
390              
391             # XXX hack filename for utf8
392 0 0         next unless $fn =~ m/[^\x00-\x80]/;
393 0           $fn = Encode::encode('MIME-B', $fn);
394 0           for ( 'Content-disposition', 'Content-type') {
395 0           my $v = $entity->head->get($_);
396 0           $v =~ s/name="(.*)"/name="$fn"/;
397 0           $entity->head->replace($_, $v);
398             }
399             }
400              
401             return
402 0           'From noreply@localhost ' .
403             $date->strftime('%a %b %d %H:%M:%S %Y') . "\n" .
404             $mail->stringify
405             ;
406             }
407              
408             package
409             Net::MitDK::ProfileManager;
410              
411 1     1   10 use Fcntl ':seek', ':flock';
  1         5  
  1         182  
412 1     1   10 use JSON::XS qw(encode_json decode_json);
  1         5  
  1         1605  
413              
414             sub new
415             {
416 0     0     my $self = bless {
417             timestamps => {},
418             homepath => undef,
419             readonly => 0,
420             }, shift;
421 0           return $self;
422             }
423              
424             sub _homepath
425             {
426              
427 0 0 0 0     if ( exists $ENV{HOME}) {
    0 0        
    0          
428 0           return $ENV{HOME};
429             } elsif ( $^O =~ /win/i && exists $ENV{USERPROFILE}) {
430 0           return $ENV{USERPROFILE};
431             } elsif ( $^O =~ /win/i && exists $ENV{WINDIR}) {
432 0           return $ENV{WINDIR};
433             } else {
434 0           return '.';
435             }
436             }
437              
438 0 0   0     sub readonly { $#_ ? $_[0]->{readonly} = $_[1] : $_[0]->{readonly} }
439              
440             sub homepath
441             {
442 0 0 0 0     $#_ ? $_[0]->{homepath} = $_[1] : ($_[0]->{homepath} // _homepath . '/.mitdk')
443             }
444              
445             sub list
446             {
447 0     0     my $self = shift;
448 0           my $home = $self->homepath;
449              
450 0 0         return unless -d $home;
451 0           my @list;
452 0           for my $profile ( <$home/*.profile> ) {
453 0 0         $profile =~ m[\/([^\/]+)\.profile] or next;
454 0           push @list, $1;
455             }
456 0           return @list;
457             }
458              
459             sub create
460             {
461 0     0     my ($self, $profile, %opt) = @_;
462 0           my $file = $self->homepath . "/$profile.profile";
463              
464 0 0         if ( -f $file ) {
465 0 0         return 2 if $opt{ok_if_exists};
466 0           return (undef, "Profile exists already");
467             }
468              
469 0   0       return $self->save($profile, $opt{payload} // {} );
470             }
471              
472             sub lock
473             {
474 0     0     my $f = shift;
475 0 0         return 1 if flock( $f, LOCK_NB | LOCK_EX);
476 0           sleep(1);
477 0 0         return 1 if flock( $f, LOCK_NB | LOCK_EX);
478 0           sleep(1);
479 0           return flock( $f, LOCK_NB | LOCK_EX);
480             }
481              
482             sub load
483             {
484 0     0     my ($self, $profile ) = @_;
485 0           my $file = $self->homepath . "/$profile.profile";
486              
487 0 0         return (undef, "No such profile") unless -f $file;
488 0           local $/;
489 0 0         open my $f, "<", $file or return (0, "Cannot open $file:$!");
490 0 0         return (undef, "Cannot acquire lock on $file") unless lock($f);
491              
492 0           my $r = <$f>;
493 0           close $f;
494              
495 0           my $json;
496 0           eval { $json = decode_json($r) };
  0            
497 0 0         return (undef, "Corrupted profile $file: $@") unless $json;
498              
499 0           $self->{timestamps}->{$profile} = time;
500              
501 0           return $json;
502             }
503              
504             sub save
505             {
506 0     0     my ($self, $profile, $hash) = @_;
507              
508 0 0         return (undef, "$profile is readonly") if $self->readonly;
509              
510 0           my $home = $self->homepath;
511 0 0         unless ( -d $home ) {
512 0 0         mkdir $home or return (undef, "Cannot create $home: $!");
513 0 0         return (undef, "cannot chmod 0750 $home:$!") unless chmod 0750, $home;
514 0 0         if ( $^O !~ /win32/i) {
515 0           my ($name,$passwd,$uid,$gid) = getpwnam('nobody');
516 0 0         return (undef, "no user `nobody`") unless defined $name;
517 0 0         return (undef, "cannot chown user:$name $home:$!") unless chown $>, $gid, $home;
518             }
519             }
520              
521 0           my $json;
522 0           my $encoder = JSON::XS->new->ascii->pretty;
523 0           eval { $json = $encoder->encode($hash) };
  0            
524 0 0         return (undef, "Cannot serialize profile: $!") if $@;
525              
526 0           my $file = "$home/$profile.profile";
527 0           my $f;
528 0 0         if ( -f $file ) {
529 0 0         open $f, "+<", $file or return (undef, "Cannot create $file:$!");
530 0 0         return (undef, "Cannot acquire lock on $file") unless lock($f);
531 0           seek $f, 0, SEEK_SET;
532 0 0         truncate $f, 0 or return (undef, "Cannot save $file:$!");
533             } else {
534 0 0         open $f, ">", $file or return (undef, "Cannot create $file:$!");
535             }
536 0 0         print $f $json or return (undef, "Cannot save $file:$!");
537 0 0         close $f or return (undef, "Cannot save $file:$!");
538              
539 0 0         if ( $^O !~ /win32/i) {
540 0 0         return (undef, "cannot chmod 0640 $file:$!") unless chmod 0640, $file;
541 0           my ($name,$passwd,$uid,$gid) = getpwnam('nobody');
542 0 0         return (undef, "no user `nobody`") unless defined $name;
543 0 0         return (undef, "cannot chown user:$name $file:$!") unless chown $>, $gid, $file;
544             }
545              
546 0           $self->{timestamps}->{$profile} = time;
547              
548 0           return 1;
549             }
550              
551             sub remove
552             {
553 0     0     my ($self, $profile) = @_;
554 0 0         unlink $self->homepath . "/$profile.profile" or return (undef, "Cannot remove $profile:$!");
555 0           return 1;
556             }
557              
558             sub refresh_needed
559             {
560 0     0     my ( $self, $profile ) = @_;
561 0 0         return 0 unless exists $self->{timestamps}->{$profile};
562              
563 0           my $file = $self->homepath . "/$profile.profile";
564 0           my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks) = stat($file);
565 0 0         return 0 unless defined $mtime;
566              
567 0           return $mtime > $self->{timestamps}->{$profile};
568             }
569              
570             1;
571              
572             =pod
573              
574             =head1 NAME
575              
576             Net::MitDK - perl API for http://mit.dk/
577              
578             =head1 DESCRIPTION
579              
580             Read-only interface for MitDK. See README for more info.
581              
582             =head1 AUTHOR
583              
584             Dmitry Karasik <dmitry@karasik.eu.org>
585              
586             =cut