File Coverage

blib/lib/Net/MitDK.pm
Criterion Covered Total %
statement 39 319 12.2
branch 0 154 0.0
condition 0 32 0.0
subroutine 13 55 23.6
pod 0 23 0.0
total 52 583 8.9


line stmt bran cond sub pod time code
1             package Net::MitDK;
2              
3 1     1   864 use strict;
  1         2  
  1         30  
4 1     1   5 use warnings;
  1         2  
  1         44  
5             our $VERSION = '0.03';
6 1     1   585 use Encode qw(encode decode);
  1         15830  
  1         94  
7 1     1   1049 use DateTime;
  1         554080  
  1         50  
8 1     1   850 use MIME::Entity;
  1         91535  
  1         46  
9 1     1   9 use MIME::Base64;
  1         2  
  1         69  
10 1     1   897 use IO::Lambda qw(:all);
  1         13796  
  1         301  
11 1     1   720 use IO::Lambda::HTTP::Client;
  1         52788  
  1         62  
12 1     1   498 use IO::Lambda::HTTP::UserAgent;
  1         12319  
  1         33  
13 1     1   463 use HTTP::Request::Common;
  1         3876  
  1         86  
14 1     1   827 use JSON::XS qw(encode_json decode_json);
  1         3412  
  1         3356  
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) = @_;
293 0           my @ret;
294              
295             return lambda {
296 0     0     my @files;
297 0           my ( $ndoc, $nfile ) = (0,0);
298 0           for my $doc ( @{ $message->{documents} } ) {
  0            
299 0           for my $file ( @{ $doc->{files} } ) {
  0            
300 0           push @files, [ $ndoc, $nfile++ ];
301             }
302 0           $nfile = 0;
303 0           $ndoc++;
304             }
305 0 0         return [] unless @files;
306              
307 0           ($ndoc, $nfile) = @{ shift @files };
  0            
308 0           context $self-> fetch_file($message, $ndoc, $nfile);
309             tail {
310 0           my ($resp, $error) = @_;
311 0 0         return ($resp, $error) unless defined $resp;
312              
313 0           push @ret, [ $ndoc, $nfile, $resp->content ];
314              
315 0 0         return \@ret unless @files;
316 0           ($ndoc, $nfile) = @{ shift @files };
  0            
317              
318 0           context $self-> fetch_file($message, $ndoc, $nfile);
319 0           again;
320 0           }};
  0            
321             }
322              
323             sub safe_encode
324             {
325 0     0 0   my ($enc, $text) = @_;
326 0           utf8::downgrade($text, 'fail silently please');
327 0 0 0       return (utf8::is_utf8($text) || $text =~ /[\x80-\xff]/) ? encode($enc, $text) : $text;
328             }
329              
330             sub assemble_mail
331             {
332 0     0 0   my ( $self, $msg, $attachments ) = @_;
333              
334 0           my $sender = $msg->{sender}->{label};
335              
336 0   0       my $received = $msg->{receivedDateTime} // '';
337 0           my $date;
338 0 0         if ( $received =~ /^(\d{4})-(\d{2})-(\d{2})T(\d\d):(\d\d):(\d\d)/) {
339 0           $date = DateTime->new(
340             year => $1,
341             month => $2,
342             day => $3,
343             hour => $4,
344             minute => $5,
345             second => $6,
346             );
347             } else {
348 0           $date = DateTime->now;
349             }
350 0           $received = $date->strftime('%a, %d %b %Y %H:%M:%S %z');
351              
352             my $mail = MIME::Entity->build(
353             From => ( safe_encode('MIME-Q', $sender) . ' <noreply@mit.dk>' ) ,
354             To => ( safe_encode('MIME-Q', $self->{session}->{mailboxes}->{ownerName}) . ' <' . ( $ENV{USER} // 'you' ) . '@localhost>' ),
355 0   0       Subject => safe_encode('MIME-Header', $msg->{label}),
356             Data => encode('utf-8', "Mail from $sender"),
357             Date => $received,
358             Charset => 'utf-8',
359             Encoding => 'quoted-printable',
360             'X-Net-MitDK' => "v/$VERSION",
361             );
362              
363 0           for ( @$attachments ) {
364 0           my ( $ndoc, $nfile, $body ) = @$_;
365 0           my $file = $msg->{documents}->[$ndoc]->{files}->[$nfile];
366 0           my $fn = $file->{filename};
367 0           Encode::_utf8_off($body);
368              
369             my $entity = $mail->attach(
370             Type => $file->{encodingFormat},
371 0           Encoding => 'base64',
372             Data => $body,
373             Filename => $fn,
374             );
375              
376             # XXX hack filename for utf8
377 0 0         next unless $fn =~ m/[^\x00-\x80]/;
378 0           $fn = Encode::encode('MIME-B', $fn);
379 0           for ( 'Content-disposition', 'Content-type') {
380 0           my $v = $entity->head->get($_);
381 0           $v =~ s/name="(.*)"/name="$fn"/;
382 0           $entity->head->replace($_, $v);
383             }
384             }
385              
386             return
387 0           'From noreply@localhost ' .
388             $date->strftime('%a %b %d %H:%M:%S %Y') . "\n" .
389             $mail->stringify
390             ;
391             }
392              
393             package
394             Net::MitDK::ProfileManager;
395              
396 1     1   10 use Fcntl ':seek', ':flock';
  1         2  
  1         140  
397 1     1   9 use JSON::XS qw(encode_json decode_json);
  1         1  
  1         1584  
398              
399             sub new
400             {
401 0     0     my $self = bless {
402             timestamps => {},
403             homepath => undef,
404             readonly => 0,
405             }, shift;
406 0           return $self;
407             }
408              
409             sub _homepath
410             {
411              
412 0 0 0 0     if ( exists $ENV{HOME}) {
    0 0        
    0          
413 0           return $ENV{HOME};
414             } elsif ( $^O =~ /win/i && exists $ENV{USERPROFILE}) {
415 0           return $ENV{USERPROFILE};
416             } elsif ( $^O =~ /win/i && exists $ENV{WINDIR}) {
417 0           return $ENV{WINDIR};
418             } else {
419 0           return '.';
420             }
421             }
422              
423 0 0   0     sub readonly { $#_ ? $_[0]->{readonly} = $_[1] : $_[0]->{readonly} }
424              
425             sub homepath
426             {
427 0 0 0 0     $#_ ? $_[0]->{homepath} = $_[1] : ($_[0]->{homepath} // _homepath . '/.mitdk')
428             }
429              
430             sub list
431             {
432 0     0     my $self = shift;
433 0           my $home = $self->homepath;
434              
435 0 0         return unless -d $home;
436 0           my @list;
437 0           for my $profile ( <$home/*.profile> ) {
438 0 0         $profile =~ m[\/([^\/]+)\.profile] or next;
439 0           push @list, $1;
440             }
441 0           return @list;
442             }
443              
444             sub create
445             {
446 0     0     my ($self, $profile, %opt) = @_;
447 0           my $file = $self->homepath . "/$profile.profile";
448              
449 0 0         if ( -f $file ) {
450 0 0         return 2 if $opt{ok_if_exists};
451 0           return (undef, "Profile exists already");
452             }
453              
454 0   0       return $self->save($profile, $opt{payload} // {} );
455             }
456              
457             sub lock
458             {
459 0     0     my $f = shift;
460 0 0         return 1 if flock( $f, LOCK_NB | LOCK_EX);
461 0           sleep(1);
462 0 0         return 1 if flock( $f, LOCK_NB | LOCK_EX);
463 0           sleep(1);
464 0           return flock( $f, LOCK_NB | LOCK_EX);
465             }
466              
467             sub load
468             {
469 0     0     my ($self, $profile ) = @_;
470 0           my $file = $self->homepath . "/$profile.profile";
471              
472 0 0         return (undef, "No such profile") unless -f $file;
473 0           local $/;
474 0 0         open my $f, "<", $file or return (0, "Cannot open $file:$!");
475 0 0         return (undef, "Cannot acquire lock on $file") unless lock($f);
476              
477 0           my $r = <$f>;
478 0           close $f;
479              
480 0           my $json;
481 0           eval { $json = decode_json($r) };
  0            
482 0 0         return (undef, "Corrupted profile $file: $@") unless $json;
483              
484 0           $self->{timestamps}->{$profile} = time;
485              
486 0           return $json;
487             }
488              
489             sub save
490             {
491 0     0     my ($self, $profile, $hash) = @_;
492              
493 0 0         return (undef, "$profile is readonly") if $self->readonly;
494              
495 0           my $home = $self->homepath;
496 0 0         unless ( -d $home ) {
497 0 0         mkdir $home or return (undef, "Cannot create $home: $!");
498 0 0         return (undef, "cannot chmod 0750 $home:$!") unless chmod 0750, $home;
499 0 0         if ( $^O !~ /win32/i) {
500 0           my ($name,$passwd,$uid,$gid) = getpwnam('nobody');
501 0 0         return (undef, "no user `nobody`") unless defined $name;
502 0 0         return (undef, "cannot chown user:$name $home:$!") unless chown $>, $gid, $home;
503             }
504             }
505              
506 0           my $json;
507 0           my $encoder = JSON::XS->new->ascii->pretty;
508 0           eval { $json = $encoder->encode($hash) };
  0            
509 0 0         return (undef, "Cannot serialize profile: $!") if $@;
510              
511 0           my $file = "$home/$profile.profile";
512 0           my $f;
513 0 0         if ( -f $file ) {
514 0 0         open $f, "+<", $file or return (undef, "Cannot create $file:$!");
515 0 0         return (undef, "Cannot acquire lock on $file") unless lock($f);
516 0           seek $f, 0, SEEK_SET;
517 0 0         truncate $f, 0 or return (undef, "Cannot save $file:$!");
518             } else {
519 0 0         open $f, ">", $file or return (undef, "Cannot create $file:$!");
520             }
521 0 0         print $f $json or return (undef, "Cannot save $file:$!");
522 0 0         close $f or return (undef, "Cannot save $file:$!");
523              
524 0 0         if ( $^O !~ /win32/i) {
525 0 0         return (undef, "cannot chmod 0640 $file:$!") unless chmod 0640, $file;
526 0           my ($name,$passwd,$uid,$gid) = getpwnam('nobody');
527 0 0         return (undef, "no user `nobody`") unless defined $name;
528 0 0         return (undef, "cannot chown user:$name $file:$!") unless chown $>, $gid, $file;
529             }
530              
531 0           $self->{timestamps}->{$profile} = time;
532              
533 0           return 1;
534             }
535              
536             sub remove
537             {
538 0     0     my ($self, $profile) = @_;
539 0 0         unlink $self->homepath . "/$profile.profile" or return (undef, "Cannot remove $profile:$!");
540 0           return 1;
541             }
542              
543             sub refresh_needed
544             {
545 0     0     my ( $self, $profile ) = @_;
546 0 0         return 0 unless exists $self->{timestamps}->{$profile};
547              
548 0           my $file = $self->homepath . "/$profile.profile";
549 0           my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks) = stat($file);
550 0 0         return 0 unless defined $mtime;
551              
552 0           return $mtime > $self->{timestamps}->{$profile};
553             }
554              
555             1;
556              
557             =pod
558              
559             =head1 NAME
560              
561             Net::MitDK - perl API for http://mit.dk/
562              
563             =head1 DESCRIPTION
564              
565             Read-only interface for MitDK. See README for more info.
566              
567             =head1 AUTHOR
568              
569             Dmitry Karasik <dmitry@karasik.eu.org>
570              
571             =cut