File Coverage

blib/lib/Firefox/Sync/Client.pm
Criterion Covered Total %
statement 30 160 18.7
branch 0 42 0.0
condition 0 22 0.0
subroutine 10 29 34.4
pod 11 19 57.8
total 51 272 18.7


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Firefox::Sync::Client - A Client for the Firefox Sync Server
4              
5             =head1 SYNOPSIS
6              
7             Simple example:
8              
9             use Firefox::Sync::Client;
10              
11             my $c = new Firefox::Sync::Client(
12             URL => 'https://your.ffsync-server.org/',
13             User => 'your@mail.address',
14             Password => 'SyncPassword',
15             SyncKey => 'x-thisx-isxxx-thexx-secre-txkey',
16             );
17              
18             my $tabs = $c->get_tabs;
19              
20             foreach my $client (@$tabs) {
21             print $client->{'payload'}->{'clientName'} . "\n";
22             foreach my $tab (@{$client->{'payload'}->{'tabs'}}) {
23             print ' ' . $tab->{'title'} . "\n";
24             print ' --> ' . $tab->{'urlHistory'}[0] . "\n";
25             }
26             print "\n";
27             }
28              
29             Advanced example, printing HTML code with all bookmarks and links. Results will be cached:
30              
31             use Firefox::Sync::Client;
32             use utf8;
33             binmode STDOUT, ':encoding(UTF-8)';
34              
35             my $c = new Firefox::Sync::Client(
36             URL => 'https://your.ffsync-server.org/',
37             User => 'your@mail.address',
38             Password => 'SyncPassword',
39             SyncKey => 'x-thisx-isxxx-thexx-secre-txkey',
40             CacheFile => '/tmp/ffsync-cache',
41             );
42            
43             my $bm = $c->get_bookmarks;
44            
45             print '' . "\n";
46             print_children(1, $bm);
47             print '' . "\n";
48            
49             sub print_children {
50             my ($h, $bm) = @_;
51            
52             foreach my $item (@$bm) {
53             if ($item->{'payload'}->{'type'} eq 'folder') {
54             print '' . $item->{'payload'}->{'title'} . '' . "\n";
55             print '
    ' . "\n";
56             print_children($h + 1, $item->{'payload'}->{'children'});
57             print '' . "\n";
58             }
59            
60             if (defined $item->{'payload'}->{'bmkUri'}) {
61             print '
  • ';
  • 62             print '' . $item->{'payload'}->{'title'} . '';
    63             print '' . "\n";
    64             }
    65             else {
    66             print '
    ' . "\n";
    67             }
    68             }
    69             }
    70              
    71             =head1 DESCRIPTION
    72              
    73             This module implements a client to the popular Firefox Sync service.
    74              
    75             More information on the server can be found at Mozilla:
    76             https://developer.mozilla.org/en-US/docs/Firefox_Sync
    77              
    78             For now, this module is only a read-only client. That means, it is possible to
    79             get some collections of things from the server by using either the specialized
    80             get_* methods or get_raw_collection(). The methods usually return an array
    81             reference.
    82              
    83             In a future release, caching and some other improvements will be realized.
    84              
    85             =head1 METHODS
    86              
    87             What each method actually returns, can be different. But it will always be a
    88             reference to an array containing hashes. Every hash has the following keys:
    89              
    90             id - The ID of the element.
    91             modified - A timestamp of the last modification
    92             payload - Contains a hash of elements. The keys are different for each collection
    93              
    94             =cut
    95              
    96             package Firefox::Sync::Client;
    97              
    98 1     1   45135 use strict;
      1         3  
      1         388  
    99 1     1   7 use warnings;
      1         1  
      1         34  
    100 1     1   1265 use utf8;
      1         16  
      1         6  
    101 1     1   1193 use MIME::Base32 qw( RFC );
      1         776  
      1         6  
    102 1     1   890 use MIME::Base64;
      1         922  
      1         84  
    103 1     1   1057 use Digest::SHA qw( sha1 hmac_sha256 );
      1         4720  
      1         114  
    104 1     1   847 use Crypt::Rijndael;
      1         926  
      1         72  
    105 1     1   1174 use JSON;
      1         18751  
      1         8  
    106 1     1   1386 use LWP::UserAgent;
      1         64303  
      1         36  
    107 1     1   10 use Storable;
      1         3  
      1         2994  
    108              
    109             our $VERSION = '0.04';
    110              
    111             our @ISA = qw(Exporter);
    112             our @EXPORT = qw(new get_raw_collection get_addons get_bookmarks get_clients get_forms get_history get_meta get_passwords get_prefs get_tabs);
    113              
    114             =over
    115              
    116             =item new(%config)
    117              
    118             Constructor. You can set the following parameters during construction:
    119              
    120             ProtocolVersion - defaults to 1.1
    121             URL - The server address
    122             User - The username or e-mail address
    123             Password - The password
    124             SyncKey - The sync/recovery key
    125             CacheFile - A file to be used for caching
    126             CacheLifetime - Lifetime of cached requests in seconds
    127              
    128             =cut
    129              
    130             sub new {
    131 0     0 1   my ($class, %args) = @_;
    132              
    133             # Get parameters and set values accordingly
    134 0           my $self = {};
    135 0   0       $self->{'protocol_version'} = $args{'ProtocolVersion'} || '1.1';
    136 0   0       $self->{'username'} = $args{'User'} || '';
    137 0   0       $self->{'password'} = $args{'Password'} || '';
    138 0   0       $self->{'sync_key'} = $args{'SyncKey'} || '';
    139 0   0       $self->{'base_url'} = $args{'URL'} || '';
    140 0   0       $self->{'cachefile'} = $args{'CacheFile'} || undef;
    141 0   0       $self->{'cachelifetime'} = $args{'CacheLifetime'} || 300;
    142              
    143             # Construct user name
    144 0 0         $self->{'username'} = lc(MIME::Base32::encode(sha1(lc($self->{'username'})))) if ($self->{'username'} =~ /[^A-Z0-9._-]/i);
    145              
    146             # Extract hostname and port from URL
    147 0 0         $self->{'base_url'} =~ /^(http|https):\/\/([^:\/]*):?(\d+)?/ or die 'Invalid URL format';
    148 0           $self->{'hostname'} = $2;
    149 0 0         $self->{'port'} = ( $3 ? $3 : ( $1 eq 'http' ? '80' : '443' ) );
        0          
    150              
    151             # Construct base url
    152 0 0         $self->{'base_url'} .= '/' unless $self->{'base_url'} =~ /\/$/;
    153 0           $self->{'base_url'} .= $self->{'protocol_version'} . '/' . $self->{'username'} . '/';
    154              
    155             # Prepare hash for keys
    156 0           $self->{'bulk_keys'} = {};
    157              
    158             # Prepare temp file if used
    159 0 0         if (defined $self->{'cachefile'}) {
    160 0           open TF, '>>', $self->{'cachefile'};
    161 0           close TF;
    162             }
    163              
    164 0           bless($self, $class);
    165 0           return $self;
    166             }
    167              
    168             =item get_raw_collection($collection)
    169              
    170             Returns an array reference containing all elements of the given collection.
    171              
    172             The following collections are tested (but other collections may also work):
    173              
    174             bookmarks
    175             prefs
    176             clients
    177             forms
    178             history
    179             passwords
    180             tabs
    181             addons
    182              
    183             You can not fetch the metadata with this method, please use get_meta() instead.
    184             Also, if you plan to do something with the 'bookmarks' collection, better use
    185             get_bookmarks(), as it returns a somewhat nicer formatted array reference.
    186              
    187             =cut
    188              
    189             sub get_raw_collection {
    190 0     0 1   my ($self, $collection) = @_;
    191              
    192             # First, fetch the keys we use for decryption later - if we haven't already
    193 0 0         $self->{'bulk_keys'} = fetch_bulk_keys($self) unless $self->{'bulk_keys'}->{'default'};
    194              
    195             # Fetch the whole collection from the server.
    196 0           my $ret = fetch_json($self, $self->{'base_url'} . 'storage/' . $collection . '?full=1');
    197              
    198             # The 'payload' elements of the fetched array contain a JSON object that
    199             # has to be decrypted.
    200 0           foreach my $item (@$ret) {
    201 0           my $json = decrypt_collection($self, decode_json($item->{'payload'}), $collection);
    202              
    203             # What we see now, looks like another JSON object, but it contains some
    204             # noise, so we first repair it, then decode it and write it back to the item.
    205 0           $json = repair_json($self, $json);
    206 0           $item->{'payload'} = decode_json($json);
    207             }
    208              
    209 0           return $ret;
    210             }
    211              
    212             =item get_addons()
    213              
    214             Returns an array of the synced add-on data.
    215              
    216             =cut
    217              
    218             sub get_addons {
    219 0     0 1   my $self = shift;
    220 0           return get_raw_collection($self, 'addons');
    221             }
    222              
    223             =item get_bookmarks()
    224              
    225             Returns all bookmark collections, folders and bookmarks in a well formatted
    226             array. That means, the references are recursively resolved in the tree.
    227              
    228             =cut
    229              
    230             sub get_bookmarks {
    231 0     0 1   my $self = shift;
    232 0           my $collection = get_raw_collection($self, 'bookmarks');
    233              
    234 0           my @tree;
    235              
    236 0           foreach my $bm (@$collection) {
    237 0 0 0       next unless $bm->{'payload'}->{'parentid'} and $bm->{'payload'}->{'parentid'} eq 'places';
    238 0           resolve_children($collection, $bm);
    239 0 0         push @tree, $bm if defined $bm;
    240             }
    241              
    242 0           return \@tree;
    243             }
    244              
    245             =item get_clients()
    246              
    247             Returns all known data of the connected Sync clients.
    248              
    249             =cut
    250              
    251             sub get_clients {
    252 0     0 1   my $self = shift;
    253 0           return get_raw_collection($self, 'clients');
    254             }
    255              
    256             =item get_forms()
    257              
    258             Returns an array of synchronized form input data.
    259              
    260             =cut
    261              
    262             sub get_forms {
    263 0     0 1   my $self = shift;
    264 0           return get_raw_collection($self, 'forms');
    265             }
    266              
    267             =item get_history()
    268              
    269             Returns the synced browser history.
    270              
    271             =cut
    272              
    273             sub get_history {
    274 0     0 1   my $self = shift;
    275 0           return get_raw_collection($self, 'history');
    276             }
    277              
    278             =item get_meta()
    279              
    280             Returns an array containing the sync metadata for the user.
    281              
    282             =cut
    283              
    284             sub get_meta {
    285 0     0 1   my $self = shift;
    286 0 0         $self->{'bulk_keys'} = fetch_bulk_keys($self) unless $self->{'bulk_keys'}->{'default'};
    287 0           my $ret = fetch_json($self, $self->{'base_url'} . 'storage/meta?full=1');
    288              
    289 0           foreach my $item (@$ret) {
    290 0           my $json = $item->{'payload'};
    291 0           $json = repair_json($self, $json);
    292 0           $item->{'payload'} = decode_json($json);
    293             }
    294              
    295 0           return $ret;
    296             }
    297              
    298             =item get_passwords()
    299              
    300             Returns all synchronized passwords. The passwords are returned
    301             unencrypted.
    302              
    303             =cut
    304              
    305             sub get_passwords {
    306 0     0 1   my $self = shift;
    307 0           return get_raw_collection($self, 'passwords');
    308             }
    309              
    310             =item get_prefs()
    311              
    312             Returns the synchronized browser preferences.
    313              
    314             =cut
    315              
    316             sub get_prefs {
    317 0     0 1   my $self = shift;
    318 0           return get_raw_collection($self, 'prefs');
    319             }
    320              
    321             =item get_tabs()
    322              
    323             Returns an array of tabs opened on each Sync client / Browser.
    324              
    325             =cut
    326              
    327             sub get_tabs {
    328 0     0 1   my $self = shift;
    329 0           return get_raw_collection($self, 'tabs');
    330             }
    331              
    332             sub resolve_children {
    333 0     0 0   my ($collection, $bm) = @_;
    334 0 0 0       if ($bm->{'payload'}->{'children'} and scalar($bm->{'payload'}->{'children'})) {
    335 0           my @children;
    336 0           foreach my $child_id (@{$bm->{'payload'}->{'children'}}) {
      0            
    337 0           my $child_bm;
    338 0           foreach (@$collection) {
    339 0 0         next unless $_->{'id'} eq $child_id;
    340 0           $child_bm = $_;
    341 0           push @children, $_;
    342             }
    343 0           resolve_children($collection, $child_bm);
    344             }
    345 0           $bm->{'payload'}->{'children'} = \@children;
    346             }
    347             }
    348              
    349             sub sync_key_to_enc_key {
    350 0     0 0   my $self = shift;
    351 0           my $s_key = $self->{'sync_key'};
    352 0           $s_key =~ s/8/l/g;
    353 0           $s_key =~ s/9/o/g;
    354 0           $s_key =~ s/-//g;
    355 0           $s_key = uc($s_key);
    356 0           my $raw_bits = MIME::Base32::decode($s_key);
    357 0           my $key = hmac_sha256('Sync-AES_256_CBC-HMAC256' . $self->{'username'} . "\x01", $raw_bits);
    358 0           return $key;
    359             }
    360              
    361             sub fetch_bulk_keys {
    362 0     0 0   my $self = shift;
    363 0           my $json = fetch_json($self, $self->{'base_url'} . 'storage/crypto/keys');
    364 0           my $keys = decrypt_collection($self, decode_json($json->{'payload'}), 'crypto');
    365 0           my $default_keys = decode_json($keys);
    366 0           $self->{'bulk_keys'}{'default'} = decode_base64($default_keys->{'default'}[0]);
    367 0           return $self->{'bulk_keys'};
    368             }
    369              
    370             sub decrypt_payload {
    371 0     0 0   my ($self, $payload, $key) = @_;
    372              
    373 0           my $c = Crypt::Rijndael->new($key, Crypt::Rijndael::MODE_CBC());
    374 0           $c->set_iv(decode_base64($payload->{'IV'}));
    375              
    376 0           my $data = $c->decrypt(decode_base64($payload->{'ciphertext'}));
    377 0           $data = repair_json($self, $data);
    378              
    379 0           return $data;
    380             }
    381              
    382             sub decrypt_collection {
    383 0     0 0   my ($self, $payload, $collection) = @_;
    384 0           my $key;
    385              
    386 0 0         if ($collection eq 'crypto') {
    387 0           $key = sync_key_to_enc_key($self);
    388             }
    389             else {
    390 0 0         if ($self->{'bulk_keys'}{$collection}) {
    391 0           $key = $self->{'bulk_keys'}{$collection};
    392             }
    393             else {
    394 0           $key = $self->{'bulk_keys'}{'default'};
    395             }
    396             }
    397              
    398 0           return decrypt_payload($self, $payload, $key);
    399             }
    400              
    401             sub fetch_json {
    402 0     0 0   my ($self, $url) = @_;
    403 0           my $res;
    404              
    405 0 0         if (defined $self->{'cachefile'}) {
    406 0 0         $self->{'cache'} = retrieve($self->{'cachefile'}) unless (-z $self->{'cachefile'});
    407              
    408 0 0 0       if (defined $self->{'cache'}->{$url} and (time - $self->{'cache'}->{$url . '_ts'} <= $self->{'cachelifetime'})) {
    409             # We have a cache hit, so simply return it
    410 0           return decode_json($self->{'cache'}->{$url}->content);
    411             }
    412             else {
    413             # Really do the request
    414 0           $res = really_fetch_json($self, $url);
    415              
    416             # Cache the request
    417 0           $self->{'cache'}->{$url} = $res;
    418 0           $self->{'cache'}->{$url . '_ts'} = time;
    419 0           store($self->{'cache'}, $self->{'cachefile'});
    420             }
    421             }
    422             else {
    423             # We don't have a cache file, so simply request the data and return it
    424 0           $res = really_fetch_json($self, $url);
    425             }
    426              
    427 0           return decode_json($res->content);
    428             }
    429              
    430             sub really_fetch_json {
    431 0     0 0   my ($self, $url) = @_;
    432              
    433             # Initialize LWP
    434 0           my $ua = LWP::UserAgent->new;
    435 0           $ua->agent ("FFsyncClient/0.1 ");
    436 0           $ua->credentials ( $self->{'hostname'} . ':' . $self->{'port'}, 'Sync', $self->{'username'} => $self->{'password'} );
    437              
    438             # Do the request
    439 0           my $res = $ua->get($url);
    440 0 0         die $res->{'_msg'} if ($res->{'_rc'} != '200');
    441              
    442 0           return $res;
    443             }
    444              
    445             sub repair_json {
    446 0     0 0   my ($self, $json) = @_;
    447 0           $json =~ s/[\x00-\x1f]*//g;
    448 0 0         $json .= '}' unless $json =~ /\}$/;
    449              
    450 0           my $left = ($json =~ tr/\{//);
    451 0           my $right = ($json =~ tr/\}//);
    452            
    453 0 0         if ($left > $right) {
        0          
    454 0           my $diff = $left - $right;
    455 0           ($json .= '}', $diff--) while ($diff > 0);
    456             }
    457             elsif ($left < $right) {
    458 0           my $diff = $right - $left;
    459 0           ($json = '{' . $json, $diff--) while ($diff > 0);
    460             }
    461              
    462 0           return $json;
    463             }
    464              
    465             1;
    466              
    467             __END__