File Coverage

blib/lib/App/Upfiles.pm
Criterion Covered Total %
statement 40 491 8.1
branch 0 222 0.0
condition 0 93 0.0
subroutine 14 43 32.5
pod 2 25 8.0
total 56 874 6.4


line stmt bran cond sub pod time code
1             # Copyright 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2017, 2020, 2023, 2024 Kevin Ryde
2              
3             # This file is part of Upfiles.
4             #
5             # Upfiles is free software; you can redistribute it and/or modify it under
6             # the terms of the GNU General Public License as published by the Free
7             # Software Foundation; either version 3, or (at your option) any later
8             # version.
9             #
10             # Upfiles is distributed in the hope that it will be useful, but WITHOUT
11             # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12             # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13             # more details.
14             #
15             # You should have received a copy of the GNU General Public License along
16             # with Upfiles. If not, see .
17              
18              
19             # Net::FTP
20             # RFC 959 - ftp
21             # RFC 1123 - program ftp minimum requirements
22             # RFC 1579 - PASV
23             # RFC 2228 - PROT
24             # RFC 3659 - Extensions to FTP (MDTM fetch, REST, MLST)
25             # RFC 4217 - SSL
26             # http://cr.yp.to/ftp.html DJB's notes
27             # https://tools.ietf.org/id/draft-somers-ftp-mfxx-04.txt MFMT etc
28             #
29             # proftpd
30             # /usr/share/doc/proftpd-doc/modules/mod_site.html
31              
32              
33             package App::Upfiles;
34 1     1   8035 use 5.010;
  1         5  
35 1     1   7 use strict;
  1         2  
  1         47  
36 1     1   7 use warnings;
  1         2  
  1         46  
37 1     1   7 use Carp;
  1         15  
  1         90  
38 1     1   6 use File::Spec;
  1         2  
  1         36  
39 1     1   5 use File::Spec::Unix;
  1         3  
  1         60  
40 1     1   890 use File::stat 1.02; # for -d operator overload
  1         14039  
  1         94  
41 1     1   24 use List::Util 'max';
  1         3  
  1         183  
42 1     1   793 use POSIX ();
  1         8386  
  1         29  
43 1     1   483 use Locale::TextDomain ('App-Upfiles');
  1         22145  
  1         5  
44 1     1   26975 use Regexp::Common 'no_defaults','Emacs';
  1         2332  
  1         3  
45              
46 1     1   3899 use FindBin;
  1         969  
  1         80  
47             my $progname = $FindBin::Script;
48              
49             our $VERSION = 16;
50              
51             # uncomment this to run the ### lines
52             # use Smart::Comments;
53              
54              
55             use constant { DATABASE_FILENAME => '.upfiles.sqdb',
56             DATABASE_SCHEMA_VERSION => 1,
57              
58             CONFIG_FILENAME => '.upfiles.conf',
59              
60             # emacs backups, autosaves, lockfiles
61 1         5 EXCLUDE_BASENAME_REGEXPS_DEFAULT => [ $RE{Emacs}{skipfile} ],
62              
63             EXCLUDE_REGEXPS_DEFAULT => [],
64 1     1   5 };
  1         1  
65              
66             #------------------------------------------------------------------------------
67             sub new {
68 3     3 1 338043 my $class = shift;
69 3         44 return bless { total_size_kbytes => 0,
70             total_count => 0,
71             change_count => 0,
72             change_size => 0,
73             verbose => 1,
74              
75             exclude_regexps_default
76             => $class->EXCLUDE_REGEXPS_DEFAULT,
77              
78             exclude_basename_regexps_default
79             => $class->EXCLUDE_BASENAME_REGEXPS_DEFAULT,
80              
81             @_ }, $class;
82             }
83              
84              
85             #------------------------------------------------------------------------------
86             sub command_line {
87 0     0 1   my ($self) = @_;
88              
89 0           my $action = '';
90             my $set_action = sub {
91 0     0     my ($new_action) = @_;
92 0 0         if ($action) {
93 0           croak __x('Cannot have both action {action1} and {action2}',
94             action1 => "--$action",
95             action2 => "--$new_action");
96             }
97 0           $action = "$new_action"; # stringize against callback object :-(
98 0           };
99              
100 0           require Getopt::Long;
101 0           Getopt::Long::Configure ('no_ignore_case',
102             'bundling');
103 0 0         if (! Getopt::Long::GetOptions ('help|?' => $set_action,
104             'verbose:+' => \$self->{'verbose'},
105             'V+' => \$self->{'verbose'},
106             'version' => $set_action,
107             'n|dry-run' => \$self->{'dry_run'},
108             'recheck' => \$self->{'recheck'},
109             'catchup' => \$self->{'catchup'},
110             )) {
111 0           return 1;
112             }
113              
114 0 0         if ($self->{'verbose'} >= 2) {
115 0           print "Verbosity level $self->{'verbose'}\n";
116             }
117 0   0       $action = 'action_' . ($action || 'upfiles');
118 0           return $self->$action;
119             }
120              
121             sub action_version {
122 0     0 0   my ($self) = @_;
123 0           print __x("upfiles version {version}\n",
124             version => $self->VERSION);
125 0 0         if ($self->{'verbose'} >= 2) {
126 0           require DBI;
127 0           require DBD::SQLite;
128 0           print __x(" Perl version {version}\n", version => $]);
129 0           print __x(" DBI version {version}\n", version => $DBI::VERSION);
130 0           print __x(" DBD::SQLite version {version}\n", version => $DBD::SQLite::VERSION);
131             }
132 0           return 0;
133             }
134              
135             sub action_help {
136 0     0 0   my ($self) = @_;
137 0           print __x("Usage: $progname [--options]\n");
138 0           print __x(" --help print this message\n");
139 0           print __x(" --version print version number (and module versions if --verbose=2)\n");
140 0           print __x(" -n, --dry-run don't do anything, just print what would be done\n");
141 0           print __x(" --verbose, --verbose=N
142             print diagnostic info, with --verbose=2 print even more info\n");
143 0           return 0;
144             }
145              
146             sub action_upfiles {
147 0     0 0   my ($self, @files) = @_;
148             ### action_upfiles() ...
149             ### @ARGV
150              
151 0 0         if (@ARGV) {
152             # files given on command line
153 0           @files = @ARGV;
154 0           @files = map {File::Spec->rel2abs($_)} @files;
  0            
155             ### @files
156 0           @files = map {$_, parent_directories($_)} @files;
  0            
157             ### @files
158 0           my %hash;
159 0           @hash{@files} = (); # hash slice
160             ### %hash
161 0           local $self->{'action_files_hash'} = \%hash;
162 0           $self->do_config_file;
163              
164             } else {
165             # all files
166 0           $self->do_config_file;
167              
168 0 0         if (! $self->{'recheck'}) {
169             print __x("changed {change_count} files {change_size_kbytes}k, total {total_count} files {total_size_kbytes}k (in 1024 byte blocks)\n",
170             change_count => $self->{'change_count'},
171             change_size_kbytes => _bytes_to_kbytes($self->{'change_size'}),
172             total_count => $self->{'total_count'},
173 0           total_size_kbytes => $self->{'total_size_kbytes'});
174             }
175             }
176 0           return 0;
177             }
178             sub _bytes_to_kbytes {
179 0     0     my ($bytes) = @_;
180 0           return POSIX::ceil($bytes/1024);
181             }
182              
183             # return a list of the directory and all parent directories of $filename
184             sub parent_directories {
185 0     0 0   my ($filename) = @_;
186 0           my @ret;
187 0           for (;;) {
188 0           my $parent = File::Spec->rel2abs(File::Basename::dirname($filename));
189 0 0         last if $parent eq $filename;
190 0           push @ret, $parent;
191 0           $filename = $parent;
192             }
193 0           return @ret;
194             }
195              
196             #------------------------------------------------------------------------------
197             sub do_config_file {
198 0     0 0   my ($self) = @_;
199 0           my $config_filename = $self->config_filename;
200 0 0         if ($self->{'verbose'} >= 2) {
201 0           print __x("config: {filename}\n",
202             filename => $config_filename);
203             }
204 0 0         if ($self->{'dry_run'}) {
205 0 0         if ($self->{'verbose'}) { print __x("dry run\n"); }
  0            
206             }
207 0           require App::Upfiles::Conf;
208 0           local $App::Upfiles::Conf::upf = $self;
209              
210 0 0         if (! defined (do { package App::Upfiles::Conf;
211 0           do $config_filename;
212             })) {
213 0 0         if (! -e $config_filename) {
214 0           croak __x("No config file {filename}",
215             filename => $config_filename);
216             } else {
217 0           croak $@;
218             }
219             }
220             }
221             sub config_filename {
222 0     0 0   my ($self) = @_;
223 0   0       return $self->{'config_filename'} // do {
224 0           require File::HomeDir;
225 0   0       my $homedir = File::HomeDir->my_home
226             // croak __('No home directory for config file (File::HomeDir)');
227 0           return File::Spec->catfile ($homedir, $self->CONFIG_FILENAME);
228             };
229             }
230              
231             #------------------------------------------------------------------------------
232              
233             my %protocol_to_class = (ftp => 'App::Upfiles::FTPlazy',
234             ftps => 'App::Upfiles::FTPlazy',
235             sftp => 'App::Upfiles::SFTPlazy',
236             );
237             sub ftp {
238 0     0 0   my ($self) = @_;
239 0           my $protocol = $self->{'protocol'};
240 0           my $options = $self->{'options'};
241             ### $options
242              
243             # Here $key becomes ftp, ftp.TLS, ftps or sftp and a corresponding type of
244             # lazy connection is cached. The two ftp or ftp.TLS could be merged by
245             # setting the TLS option dynamically, but expect normally to be using just
246             # one or the other.
247 0           my $key = $protocol;
248 0 0 0       if ($protocol eq 'ftp' && $options->{'use_TLS'}) {
249 0           $key .= '.TLS';
250             }
251             return ($self->{'ftp'}->{$key}
252 0   0       //= do {
253             my $class = $protocol_to_class{$protocol}
254             or croak __x('Unrecognised protocol to remote: {protocol}',
255 0 0         protocol => $self->{'protocol'});
256 0           require Module::Load;
257 0           Module::Load::load($class);
258             $class->new (verbose => $self->{'verbose'},
259             copy_time => $options->{'copy_utime'}?1:0, # for SFTP
260             Passive => $options->{'passive'}?1:0,
261             ($protocol eq 'ftps'
262             ? (use_SSL => 1)
263 0 0         : (use_TLS => $options->{'use_TLS'})),
    0          
    0          
264             )
265             });
266             }
267              
268             sub ftp_connect {
269 0     0 0   my ($self) = @_;
270 0           my $ftp = $self->ftp;
271             $ftp->ensure_all
272             or croak __x("{protocol} error on {hostname}: {ftperr}",
273 0 0         protocol => $self->{'protocol'},
274             hostname => $ftp->host,
275             ftperr => scalar($ftp->message));
276             }
277              
278              
279             # return ($mtime, $size) of last send of $filename to url $remote
280             sub db_get_mtime {
281 0     0 0   my ($self, $dbh, $remote, $filename) = @_;
282 0           my $sth = $dbh->prepare_cached
283             ('SELECT mtime,size FROM sent WHERE remote=? AND filename=?');
284 0           my $aref = $dbh->selectall_arrayref($sth, undef, $remote, $filename);
285 0   0       $aref = $aref->[0] || return; # if no rows
286 0           my ($mtime, $size) = @$aref;
287 0           $mtime = timestamp_to_timet($mtime);
288 0           return ($mtime, $size);
289             }
290              
291             sub db_set_mtime {
292 0     0 0   my ($self, $dbh, $remote, $filename, $mtime, $size) = @_;
293 0 0         if ($self->{'verbose'} >= 2) {
294 0           print " database write $filename time=$mtime,size=$size\n";
295             }
296 0           $mtime = timet_to_timestamp($mtime);
297 0           my $sth = $dbh->prepare_cached
298             ('INSERT OR REPLACE INTO sent (remote,filename,mtime,size)
299             VALUES (?,?,?,?)');
300 0           $sth->execute ($remote, $filename, $mtime, $size);
301             }
302              
303             sub db_delete_mtime {
304 0     0 0   my ($self, $dbh, $remote, $filename) = @_;
305 0 0         if ($self->{'verbose'} >= 2) {
306 0           print " database delete $filename\n";
307             }
308 0           my $sth = $dbh->prepare_cached
309             ('DELETE FROM sent WHERE remote=? AND filename=?');
310 0           $sth->execute ($remote, $filename);
311             }
312              
313             sub db_remote_filenames {
314 0     0 0   my ($dbh, $remote) = @_;
315 0           my $sth = $dbh->prepare_cached
316             ('SELECT filename FROM sent WHERE remote=?');
317 0           return @{$dbh->selectcol_arrayref($sth, undef, $remote)};
  0            
318             }
319              
320             # return a DBD::SQLite handle for database $db_filename
321             sub dbh {
322 0     0 0   my ($self, $db_filename) = @_;
323              
324 0 0         if ($self->{'verbose'} >= 2) {
325 0           print "database open $db_filename\n";
326             }
327              
328 0           require DBD::SQLite;
329 0           my $dbh = DBI->connect ("dbi:SQLite:dbname=$db_filename",
330             '', '', {RaiseError=>1});
331 0           $dbh->func(90_000, 'busy_timeout'); # 90 seconds
332              
333             {
334 0           my ($dbversion) = do {
  0            
335 0           local $dbh->{RaiseError} = undef;
336 0           local $dbh->{PrintError} = undef;
337 0           $dbh->selectrow_array
338             ("SELECT value FROM extra WHERE key='database-schema-version'")
339             };
340 0   0       $dbversion ||= 0;
341 0 0         if ($dbversion < $self->DATABASE_SCHEMA_VERSION) {
342 0           $self->_upgrade_database ($dbh, $dbversion, $db_filename);
343             }
344             }
345 0           return $dbh;
346             }
347              
348             sub _upgrade_database {
349 0     0     my ($self, $dbh, $dbversion, $db_filename) = @_;
350              
351 0 0         if ($dbversion <= 0) {
352             # dbversion=0 is an empty database
353 0 0         if ($self->{'verbose'}) { print __x("initialize {filename}\n",
  0            
354             filename => $db_filename); }
355 0           $dbh->do (<<'HERE');
356             CREATE TABLE extra (
357             key TEXT NOT NULL PRIMARY KEY,
358             value TEXT
359             )
360             HERE
361 0           $dbh->do (<<'HERE');
362             CREATE TABLE sent (
363             remote TEXT NOT NULL,
364             filename TEXT NOT NULL,
365             mtime TEXT NOT NULL,
366             size INTEGER NOT NULL,
367             PRIMARY KEY (remote, filename)
368             )
369             HERE
370             }
371              
372 0           $dbh->do ("INSERT OR REPLACE INTO extra (key,value)
373             VALUES ('database-schema-version',?)",
374             undef,
375             $self->DATABASE_SCHEMA_VERSION);
376             }
377              
378              
379             #------------------------------------------------------------------------------
380             sub upfiles {
381 0     0 0   my ($self, %options) = @_;
382              
383 0 0         if (! exists $options{'copy_utime'}) {
384             # default
385 0           $options{'copy_utime'} = 'if_possible';
386             }
387              
388 0 0         if ($self->{'verbose'} >= 3) {
389 0           require Data::Dumper;
390 0           print Data::Dumper->new([\%options],['options'])->Sortkeys(1)->Dump;
391             }
392 0   0       my $local_dir = $options{'local'}
393             // croak __('No local directory specified');
394              
395 0   0       my $remote = $options{'remote'} // croak __('No remote target specified');
396 0           require URI;
397 0 0         if (! eval { require URI::ftps }) {
  0            
398             ### use App-Upfiles-URI-ftps ...
399 0           require App::Upfiles::URI::ftps;
400 0           URI::implementor('ftps','App::Upfiles::URI::ftps');
401             }
402 0 0         my $remote_uri = ($remote =~ /^ftps:/ ? "URI::ftp" : "URI")->new($remote);
403 0           my $remote_dir = $remote_uri->path;
404 0           local $self->{'protocol'} = $remote_uri->scheme;
405 0           local $self->{'host'} = $remote_uri->host;
406 0           local $self->{'username'} = $remote_uri->user;
407 0           local $self->{'remote_dir'} = $remote_dir;
408 0           local $self->{'options'} = \%options;
409              
410 0 0         defined $self->{'username'}
411             or croak __('No username given in remote URL');
412              
413 0 0         if ($self->{'verbose'}) {
414             # TRANSLATORS: any need to translate this? maybe the -> arrow
415             print __x("{localdir} -> {protocol} {username}\@{hostname} {remotedir}\n",
416             localdir => $local_dir,
417             protocol => $self->{'protocol'},
418             username => $self->{'username'},
419 0           hostname => $self->{'host'},
420             remotedir => $remote_dir);
421             }
422              
423             # Go to local directory to notice if it doesn't exist, before attempting
424             # to open/create the database.
425 0 0         chdir $local_dir
426             or croak __x("Cannot chdir to local directory {localdir}: {strerror}",
427             localdir => $local_dir,
428             strerror => "$!");
429              
430 0           my $ftp = $self->ftp;
431             ($ftp->host ($self->{'host'})
432             && $ftp->login ($self->{'username'})
433             && $ftp->binary)
434             or croak __x("{protocol} error on {hostname}: {ftperr}",
435             protocol => $self->{'protocol'},
436 0 0 0       hostname => $self->{'host'},
      0        
437             ftperr => scalar($self->ftp->message));
438              
439 0 0         if ($self->{'recheck'}) {
440 0           $self->recheck();
441 0           return;
442             }
443              
444 0           my $db_filename = File::Spec->catfile ($local_dir, $self->DATABASE_FILENAME);
445 0           my $dbh = $self->dbh ($db_filename);
446              
447             {
448             # initial creation of remote dir
449 0           my ($remote_mtime, $remote_size)
450 0           = $self->db_get_mtime ($dbh, $options{'remote'}, '/');
451 0 0         if (! $remote_mtime) {
452 0           my $unslashed = $remote_dir;
453 0           $unslashed =~ s{/$}{};
454 0 0         if ($self->{'verbose'}) {
455 0           print __x("MKD toplevel {dirname}\n",
456             dirname => $remote_dir);
457             }
458              
459 0 0         unless ($self->{'dry_run'}) {
460 0           $self->ftp_connect;
461 0   0       $self->ftp->mkdir ($unslashed, 1)
462             // croak __x("Cannot make directory {dirname}: {ftperr}",
463             dirname => $remote_dir,
464             ftperr => scalar($self->ftp->message));
465 0           $self->db_set_mtime ($dbh, $options{'remote'}, '/', 1, 1);
466             }
467             }
468             }
469 0           $ftp->cwd ($remote_dir);
470              
471              
472             # =item C (arrayref of regexps)
473             #
474             # Patterns of filenames to sort last for uploading. For example to upload
475             # all index files last
476             #
477             # upfiles (local => '/my/directory',
478             # remote => 'ftp://some-server.org/pub/fred',
479             # sort_last_regexps => [ qr{index\.html$} ]);
480             #
481             # The upload order is all files not "last", then all files matching the
482             # first "last" regexp, then those matching the second "last" regexp, etc.
483             # If a filename matches multiple regexps then the last one it matches is
484             # used for its upload position.
485             #
486             # This option can be used to upload an index, contents list, site map,
487             # etc, after uploads of content it refers to. This suits simple
488             # references (but is probably not enough for mutual dependencies).
489              
490 0           my $local_filenames_hash = $self->local_filenames_hash;
491 0           my $sort_last_regexps = $options{'sort_last_regexps'};
492 0           my @local_filenames = keys %$local_filenames_hash;
493 0           foreach my $filename (@local_filenames) {
494 0           foreach my $i (0 .. $#$sort_last_regexps) {
495             ### $filename
496             ### re: $sort_last_regexps->[$i]
497 0 0         if ($filename =~ $sort_last_regexps->[$i]) {
498 0           $local_filenames_hash->{$filename} = 10 + $i;
499             ### set: 10+$i
500             }
501             }
502             }
503             @local_filenames = sort
504 0 0         {$local_filenames_hash->{$a} <=> $local_filenames_hash->{$b}
  0            
505             || $a cmp $b}
506             @local_filenames;
507              
508 0           my $any_changes = 0;
509 0           foreach my $filename (@local_filenames) {
510              
511             # Reject \r\n here so as to keep any \r\n out of the database.
512             # Don't want to note a \r\n tempfile in the database, have Net::FTP
513             # reject it, and then be left with the database claiming a \r\n file
514             # exists and should be deleted.
515 0 0         if ($filename =~ /[\r\n]/s) {
516 0           croak __x("FTP does not support filenames with CR or LF characters: {filename}",
517             filename => $filename);
518             }
519              
520 0 0         if (my $action_files_hash = $self->{'action_files_hash'}) {
521 0           my $filename_abs = File::Spec->rel2abs($filename);
522             ### $filename_abs
523 0 0         if (! exists $action_files_hash->{$filename_abs}) {
524 0           next;
525             }
526             ### included in action_files_hash ...
527             }
528              
529 0 0         if ($self->{'verbose'} >= 2) {
530 0           print __x("local: {filename}\n", filename => $filename);
531             }
532 0           my $isdir = ($filename =~ m{/$});
533              
534             my ($remote_mtime, $remote_size)
535 0           = $self->db_get_mtime ($dbh, $options{'remote'}, $filename);
536 0   0       my $local_st = File::stat::stat($filename)
537             // next; # if no longer exists
538 0 0         my $local_mtime = ($isdir ? 1 : $local_st->mtime);
539 0 0         my $local_size = ($isdir ? 1 : $local_st->size);
540              
541 0 0         if ($self->{'verbose'} >= 2) {
542 0   0       print " local time=$local_mtime,size=$local_size ",
      0        
543             "remote time=",$remote_mtime//'undef',
544             ",size=",$remote_size//'undef',"\n";
545             }
546              
547 0 0 0       if (defined $remote_mtime && $remote_mtime == $local_mtime
      0        
      0        
548             && defined $remote_size && $remote_size == $local_size) {
549 0 0         if ($self->{'verbose'} >= 2) {
550 0           print __x(" unchanged\n");
551             }
552 0           next;
553             }
554              
555 0 0         unless ($self->{'catchup'}) {
556 0 0         if ($isdir) {
557             # directory, only has to exist
558 0           my $unslashed = $filename;
559 0           $unslashed =~ s{/$}{};
560 0 0         if ($self->{'verbose'}) {
561 0           print __x("MKD {dirname}\n",
562             dirname => $filename);
563             }
564 0           $self->{'change_count'}++;
565 0           $any_changes = 1;
566 0 0         next if $self->{'dry_run'};
567              
568 0           $self->ftp_connect;
569 0   0       $self->ftp->mkdir ($unslashed, 1)
570             // croak __x("Cannot make directory {dirname}: {ftperr}",
571             dirname => $filename,
572             ftperr => scalar($self->ftp->message));
573              
574             } else {
575             # file, must exist and same modtime
576 0           my $size_bytes = -s $filename;
577 0 0         if ($self->{'verbose'}) {
578 0           my $size_kbytes = max (0.1, $size_bytes/1024);
579 0 0         $size_kbytes = sprintf('%.*f',
580             ($size_kbytes >= 10 ? 0 : 1), # decimals
581             $size_kbytes);
582 0           print __x("PUT {filename} [{size_kbytes}k]\n",
583             filename => $filename,
584             size_kbytes => $size_kbytes);
585             }
586 0           $self->{'change_count'}++;
587 0           $self->{'change_size'} += $size_bytes;
588 0           $any_changes = 1;
589 0 0         next if $self->{'dry_run'};
590              
591 0           my $tmpname = "$filename.tmp.$$";
592 0 0         if ($self->{'verbose'} >= 2) {
593 0           print " with tmpname $tmpname\n";
594             }
595 0           $self->db_set_mtime ($dbh, $options{'remote'}, $tmpname,
596             $local_mtime, $local_size);
597              
598             {
599 0           $self->ftp_connect;
  0            
600 0           my $put;
601 0 0         if (my $throttle_options = $options{'throttle'}) {
602 0           require App::Upfiles::Tie::Handle::Throttle;
603 0           require Symbol;
604 0           my $fh = Symbol::gensym();
605 0           tie *$fh, 'App::Upfiles::Tie::Handle::Throttle',
606             %$throttle_options;
607             ### tied: $fh
608             ### tied: tied($fh)
609 0 0         open $fh, '<', $filename
610             or croak __x("Cannot open {filename}: {strerror}",
611             filename => $filename,
612             strerror => $!);
613 0           $put = $self->ftp->put ($fh, $tmpname);
614 0 0         close $fh
615             or croak __x("Error closing {filename}: {strerror}",
616             filename => $filename,
617             strerror => $!);
618             } else {
619 0           $put = $self->ftp->put ($filename, $tmpname);
620             }
621 0 0         $put or croak __x("Error sending {filename}: {ftperr}",
622             filename => $filename,
623             ftperr => scalar($self->ftp->message));
624             }
625              
626 0 0         if ($self->{'verbose'} >= 2) {
627 0           print " rename\n";
628             }
629 0 0         $self->ftp->rename ($tmpname, $filename)
630             or croak __x("Cannot rename {filename}: {ftperr}",
631             filename => $tmpname,
632             ftperr => scalar($self->ftp->message));
633 0           $self->db_delete_mtime ($dbh, $options{'remote'}, $tmpname);
634              
635 0           $self->site_utime($filename, $local_st);
636             }
637             }
638 0           $self->db_set_mtime ($dbh, $options{'remote'}, $filename,
639             $local_mtime, $local_size);
640             }
641              
642             # reverse to delete contained files before their directory ...
643 0           foreach my $filename (reverse db_remote_filenames($dbh, $options{'remote'})) {
644 0 0         next if $local_filenames_hash->{$filename};
645 0 0         if (my $action_files_hash = $self->{'action_files_hash'}) {
646 0 0         if (! exists $action_files_hash->{$filename}) {
647 0           next;
648             }
649             }
650 0           my $isdir = ($filename =~ m{/$});
651              
652 0 0         unless ($self->{'catchup'}) {
653 0 0         if ($isdir) {
654 0           my $unslashed = $filename;
655 0           $unslashed =~ s{/$}{};
656 0 0         if ($self->{'verbose'}) { print __x("RMD {filename}\n",
  0            
657             filename => $filename); }
658 0           $self->{'change_count'}++;
659 0           $any_changes = 1;
660 0 0         next if $self->{'dry_run'};
661              
662 0           $self->ftp_connect;
663 0 0         $self->ftp->rmdir ($unslashed, 1)
664             or warn "Cannot rmdir $unslashed: ", $self->ftp->message;
665              
666             } else {
667 0 0         if ($self->{'verbose'}) { print __x("DELE {filename}\n",
  0            
668             filename => $filename); }
669 0           $self->{'change_count'}++;
670 0           $any_changes = 1;
671 0 0         next if $self->{'dry_run'};
672              
673 0           $self->ftp_connect;
674 0 0         $self->ftp->delete ($filename)
675             or warn "Cannot delete $filename: ", $self->ftp->message;
676             }
677             }
678 0           $self->db_delete_mtime ($dbh, $options{'remote'}, $filename);
679             }
680              
681             $ftp->all_ok
682             or croak __x("ftp error on {hostname}: {ftperr}",
683 0 0         hostname => $self->{'host'},
684             ftperr => scalar($self->ftp->message));
685              
686 0 0         if (! $any_changes) {
687 0 0         if ($self->{'verbose'}) { print ' ',__('no changes'),"\n"; }
  0            
688             }
689              
690 0           return 1;
691             }
692              
693             # $filename is a remote filename.
694             # $local_st is a File::stat of the corresponding local file.
695             #
696             # Set the file modification time on remote $filename to $local_st, using the
697             # method (if any) specified by copy_utime, including possibly testing what
698             # method the server supports (MFMT, SITE UTIME, etc).
699             #
700             # When guessing the method supported on the server, the method found to work
701             # is stored to $options->{'copy_utime'} in order to use the same later
702             # without testing.
703             #
704             sub site_utime {
705 0     0 0   my ($self, $filename, $local_st) = @_;
706 0           my $options = $self->{'options'};
707 0 0         return if ! $options->{'copy_utime'};
708 0 0         return if $self->{'protocol'} eq 'sftp';
709              
710             # MFMT as per https://tools.ietf.org/id/draft-somers-ftp-mfxx-04.txt
711             # MFMT YYYYMMDDhhmmss path
712             # mtime, optional .milliseconds too, not used here
713 0 0 0       if ($options->{'copy_utime'} ne '2arg' && $options->{'copy_utime'} ne '5arg') {
714 0           my $ret = $self->ftp->quot('MFMT',
715             timet_to_ymdhms($local_st->mtime),
716             $filename);
717 0 0         if ($ret == 2) { # OK
718 0           $options->{'copy_utime'} = 'MFMT';
719 0           return 1;
720             }
721              
722             # not OK
723             # If copy_utime==MFMT then it must work,
724             # otherwise anything except 500 not implemented is bad.
725             # 500 not implemented with "if_possible" means keep trying.
726 0           my $code = $self->ftp->code;
727 0 0 0       if ($options->{'copy_utime'} eq 'MFMT' || $code != 500) {
728 0           my $message = $self->ftp->message;
729 0           croak __x("Cannot MFMT {filename}: {ftperr}",
730             filename => $filename,
731             ftperr => $message);
732             }
733             }
734              
735             # SITE UTIME YYYYMMDDhhmm[ss] path
736             # mtime
737             # proftpd style 2-arg
738 0 0 0       if ($options->{'copy_utime'} ne 'MFMT' && $options->{'copy_utime'} ne '5arg') {
739 0           my $ret = $self->ftp->site('UTIME',
740             timet_to_ymdhms($local_st->mtime),
741             $filename);
742 0 0         if ($ret == 2) { # OK
743 0           $options->{'copy_utime'} = '2arg';
744 0           return 1;
745             }
746              
747             # not OK
748             # If copy_utime==2arg then it must work,
749             # otherwise anything except 500 not implemented is bad.
750             # 500 not implemented with "if_possible" means keep trying.
751 0           my $code = $self->ftp->code;
752 0 0 0       if ($options->{'copy_utime'} eq '2arg' || $code != 500) {
753 0           my $message = $self->ftp->message;
754 0           croak __x("Cannot 2-arg SITE UTIME {filename}: {ftperr}",
755             filename => $filename,
756             ftperr => $message);
757             }
758             }
759              
760             # SITE UTIME path YYYYMMDDhhmm[ss] YYYYMMDDhhmm[ss] YYYYMMDDhhmm[ss] UTC
761             # atime, mtime, ctime
762             # pure-ftpd style
763             # pure-ftpd 1.0.33 up has MFMT (and 2-arg SITE UTIME too), but this 5-arg
764             # helps older versions still in use
765 0 0 0       if ($options->{'copy_utime'} ne 'MFMT' && $options->{'copy_utime'} ne '2arg') {
766 0           my $ret = $self->ftp->site('UTIME',
767             $filename,
768             timet_to_ymdhms($local_st->atime),
769             timet_to_ymdhms($local_st->mtime),
770             timet_to_ymdhms($local_st->ctime),
771             "UTC");
772 0 0         if ($ret == 2) { # OK
773 0           $options->{'copy_utime'} = '5arg';
774 0           return 1;
775             }
776              
777             # not OK
778             # If copy_utime==5arg then it must work,
779             # otherwise anything except 500 not implemented is bad.
780             # 500 not implemented with "if_possible" means keep trying.
781 0           my $code = $self->ftp->code;
782 0 0 0       if ($options->{'copy_utime'} eq '5arg' || $code != 500) {
783 0           my $message = $self->ftp->message;
784 0           croak __x("Cannot 5-arg SITE UTIME {filename}: {ftperr}",
785             filename => $filename,
786             ftperr => $message);
787             }
788             }
789              
790 0 0         if ($options->{'copy_utime'} eq 'if_possible') {
791             # SITE UTIME command not available
792 0           $options->{'copy_utime'} = 0;
793 0           print ' ',__('(no SITE UTIME on this server)'),"\n";
794 0           return 0;
795             }
796              
797             # copy_utime is true, meaning must have one of the methods
798 0           croak __("Cannot copy_utime, neither MFMT nor SITE UTIME available on server");
799             }
800              
801             # Return a hashref { $filename => 1 } which is all the local filenames.
802             # "exclude_regexps" etc are applied.
803             # "action_files" etc are not applied, so local_filenames_hash is all local
804             # filenames, of which perhaps only some are to be acted on in this run.
805             #
806             sub local_filenames_hash {
807 0     0 0   my ($self) = @_;
808 0           my $options = $self->{'options'};
809              
810             # $self->{'total_size_kbytes'} = 0;
811             # $self->{'total_count'} = 0;
812              
813 0           my $local_dir = $options->{'local'};
814              
815 0           my @exclude_regexps = (@{$self->{'exclude_regexps_default'}},
816 0   0       @{$options->{'exclude_regexps'} // []});
  0            
817 0 0         if ($self->{'verbose'} >= 3) {
818 0           print "exclude regexps\n";
819 0           foreach my $re (@exclude_regexps) { print " $re\n"; }
  0            
820             }
821              
822 0           my @exclude_basename_regexps = (@{$self->EXCLUDE_BASENAME_REGEXPS_DEFAULT},
823 0   0       @{$options->{'exclude_basename_regexps'}
  0            
824             // []});
825 0 0         if ($self->{'verbose'} >= 3) {
826 0           print "exclude basename regexps\n";
827 0           foreach my $re (@exclude_basename_regexps) { print " $re\n"; }
  0            
828             }
829              
830             # ".upfiles.sqdb" database file
831             # ".upfiles.sqdb-journal" file if interrupted on previous run
832 0           my $database_filename = $self->DATABASE_FILENAME;
833 0           my $database_journal_filename = $database_filename . '-journal';
834              
835 0           my %local_filenames_hash = ('/' => 1);
836             my $wanted = sub {
837 0     0     my $fullname = $File::Find::name;
838 0           my $basename = File::Basename::basename ($fullname);
839              
840 0 0 0       if ($basename eq $database_filename
841             || $basename eq $database_journal_filename) {
842 0           $File::Find::prune = 1;
843 0           return;
844             }
845 0           foreach my $exclude (@{$options->{'exclude'}}) {
  0            
846 0 0         if ($basename eq $exclude) {
847 0           $File::Find::prune = 1;
848 0           return;
849             }
850             }
851 0           foreach my $re (@exclude_basename_regexps) {
852 0 0 0       if (defined $re && $basename =~ $re) {
853 0           $File::Find::prune = 1;
854 0           return;
855             }
856             }
857 0           foreach my $re (@exclude_regexps) {
858 0 0 0       if (defined $re && $fullname =~ $re) {
859 0           $File::Find::prune = 1;
860 0           return;
861             }
862             }
863              
864 0   0       my $st = File::stat::stat($fullname)
865             || croak __x("Cannot stat {filename}: {strerror}",
866             filename => $fullname,
867             strerror => $!);
868 0 0         unless (-d $st) {
869 0           $self->{'total_size_kbytes'} += _bytes_to_kbytes($st->size);
870 0           $self->{'total_count'}++;
871             }
872             ### $fullname
873             ### size: _bytes_to_kbytes($st->size)
874             ### total: $self->{'total_size_kbytes'}
875             ### isdir: -d $st
876              
877 0           my $relname = File::Spec->abs2rel ($fullname, $local_dir);
878 0 0         return if $relname eq '.';
879 0 0         if (-d $fullname) {
880 0           $relname .= '/'; # directory names foo/
881             }
882              
883 0           $local_filenames_hash{$relname} = 1;
884 0           };
885              
886 0           require File::Find;
887             File::Find::find ({ wanted => $wanted,
888             no_chdir => 1,
889 0     0     preprocess => sub { sort @_ },
890             },
891 0           $local_dir);
892              
893 0 0         if ($self->{'verbose'} >= 3) {
894 0           print "local filenames count $self->{'total_count'} total size $self->{'total_size_kbytes'} kbytes\n";
895             }
896              
897             ### %local_filenames_hash
898 0           return \%local_filenames_hash;
899             }
900              
901             sub recheck {
902 0     0 0   my ($self) = @_;
903 0           my $options = $self->{'options'};
904 0           my $local_filenames_hash = $self->local_filenames_hash;
905              
906 0           my $local_dir = $options->{'local'};
907 0           my $db_filename = File::Spec->catfile ($local_dir, $self->DATABASE_FILENAME);
908 0           my $dbh = $self->dbh ($db_filename);
909              
910 0           my $ftp = $self->ftp;
911 0           my $remote_dir = $self->{'remote_dir'};
912 0           my @pending_directories = ('');
913 0           my %seen;
914              
915 0           my %db_filenames = map { $_ => 1 } db_remote_filenames($dbh, $options->{'remote'});
  0            
916             ### %db_filenames
917              
918 0           my $count_remote_extra = 0;
919 0           my $count_remote_missing = 0;
920              
921 0           while (@pending_directories) {
922 0           my $dirname = shift @pending_directories; # depth first
923             ### $dirname
924              
925 0           my $remote_dirname = File::Spec::Unix->catdir($remote_dir, $dirname);
926 0 0         if ($self->{'verbose'} >= 2) {
927 0           print "remote dir $remote_dirname\n";
928             }
929 0           $ftp->cwd($remote_dirname);
930              
931 0           my @lines = $ftp->mlsd(''); # listing of current dir
932             ### @lines
933 0 0         if (! $ftp->ok) {
934 0           print $ftp->message,"\n";
935 0           return;
936             }
937              
938 0           @lines = sort { my ($filename1) = MLSD_line_parse($a);
  0            
939 0           my ($filename2) = MLSD_line_parse($b);
940 0           $filename1 cmp $filename2;
941             } @lines;
942              
943 0           foreach my $line (@lines) {
944 0           my ($filename, %facts) = MLSD_line_parse($line);
945             ### $line
946             ### $filename
947 0   0       my $type = $facts{'type'} // '';
948 0 0         if ($dirname ne '') { $filename = "$dirname/$filename"; }
  0            
949              
950 0 0         if ($type eq 'file') {
    0          
951 0           delete $db_filenames{$filename};
952              
953 0           my $remote_size = $facts{'size'};
954 0 0         if (! defined $remote_size) {
955 0           print __x("{filename} no size from server\n",
956             filename => $filename);
957 0           next;
958             }
959              
960             my ($db_mtime, $db_size)
961 0           = $self->db_get_mtime ($dbh, $options->{'remote'}, $filename);
962 0 0         if (! defined $db_size) {
963 0   0       my $modify = $facts{'modify'} // __('[unknown]');
964 0           print __x("{filename} extra on remote (size {remote_size} modified {modify})\n",
965             filename => $filename,
966             remote_size => $remote_size,
967             modify => $modify);
968 0           $count_remote_extra++;
969 0           next;
970             }
971              
972 0 0         if ($remote_size != $db_size) {
973 0           print __x("{filename} different size (expected {db_size}, remote {remote_size})\n",
974             filename => $filename,
975             db_size => $db_size,
976             remote_size => $remote_size);
977             }
978              
979             } elsif ($type eq 'dir') {
980 0           my $unique = $facts{'unique'};
981 0 0 0       if (defined $unique && $seen{$unique}++) {
982 0           next;
983             }
984 0           push @pending_directories, $filename;
985 0           delete $db_filenames{$filename.'/'};
986             }
987             }
988              
989 0 0         my $dirname_re = ($dirname eq '' ? qr{^[^/]+$} : qr{^\Q$dirname/\E[^/]+$});
990 0           foreach my $filename (sort keys %db_filenames) {
991 0 0         next unless $filename =~ $dirname_re;
992 0           delete $db_filenames{$filename};
993 0 0         if ($filename =~ m{/$}) {
994 0           hash_delete_regexp(\%db_filenames, qr{^\Q$dirname/\E[^/]+/});
995             }
996 0           print __x("{filename} missing on remote\n",
997             filename => $filename);
998 0           $count_remote_missing++;
999             }
1000             }
1001              
1002 0           print __x("remote extra {count_extra}, missing {count_missing}\n",
1003             count_extra => $count_remote_extra,
1004             count_missing => $count_remote_missing);
1005             }
1006              
1007             # $str is like
1008             # "type=file;size=2061;UNIX.mode=0644; index.html"
1009             # Return a list ($filename, key => value, key => value, ...) which are
1010             # the filename part and the "facts" about it.
1011             # The fact keys are forced to lower case since RFC 3659 specifies them as
1012             # case-insensitive.
1013             sub MLSD_line_parse {
1014 0     0 0   my ($str) = @_;
1015 0 0         $str =~ /(.*?) (.*)$/ or return;
1016 0           my $facts = $1;
1017 0           my $filename = $2;
1018 0           return ($filename, MLST_facts_parse($facts));
1019             }
1020             # $str is the facts part like
1021             # type=file;size=2061;modify=20150304222544;UNIX.mode=0644; index.html
1022             # Return a list (key => value, key => value, ...)
1023             # The fact keys are forced to lower case since RFC 3659 specifies them as
1024             # case-insensitive.
1025             sub MLST_facts_parse {
1026 0     0 0   my ($str) = @_;
1027 0           return map { my ($key, $value) = split /=/, $_, 2;
  0            
1028 0           lc($key) => $value }
1029             split /;/, $str;
1030             }
1031              
1032              
1033             #------------------------------------------------------------------------------
1034             # misc helpers
1035              
1036             # # return size of $filename in kbytes
1037             # sub file_size_kbytes {
1038             # my ($filename) = @_;
1039             # return _bytes_to_kbytes(-s $filename);
1040             # }
1041              
1042             # # return st_mtime (an integer) of $filename, or undef if unable
1043             # sub stat_mtime {
1044             # my ($filename) = @_;
1045             # my $st = File::stat::stat($filename) // return undef;
1046             # return $st->mtime;
1047             # }
1048              
1049             # # $st is a File::stat. Return the disk space occupied by the file, based on
1050             # # the file size rounded up to the next whole block.
1051             # # my $blksize = $st->blksize || 1024;
1052             # sub st_space {
1053             # my ($st) = @_;
1054             # my $blksize = 1024;
1055             # require Math::Round;
1056             # return scalar (Math::Round::nhimult ($blksize, $st->size));
1057             # }
1058              
1059             # $t is a time_t time() style seconds since the epoch.
1060             # Return a string YYYYMMDDHHMMSS in GMT as for MFMT and SITE UTIME.
1061             sub timet_to_ymdhms {
1062 0     0 0   my ($t) = @_;
1063 0           return POSIX::strftime ('%Y%m%d%H%M%S', gmtime($t));
1064             }
1065              
1066             # $t is a time_t time() style seconds since the epoch.
1067             # Return a string like "2001-12-31 23:59:00+00:00" which is the timestamp
1068             # format in the upfiles database.
1069             sub timet_to_timestamp {
1070 0     0 0   my ($t) = @_;
1071 0           return POSIX::strftime ('%Y-%m-%d %H:%M:%S+00:00', gmtime($t));
1072             }
1073             sub timestamp_to_timet {
1074 0     0 0   my ($timestamp) = @_;
1075 0           my ($year, $month, $day, $hour, $minute, $second)
1076             = split /[- :+]/, $timestamp;
1077 0           require Time::Local;
1078 0           return Time::Local::timegm_modern
1079             ($second, $minute, $hour, $day, $month-1, $year);
1080             }
1081              
1082             # $href is a hashref and $re a regexp. Delete all keys matching $re.
1083             sub hash_delete_regexp {
1084 0     0 0   my ($href, $re) = @_;
1085 0           while (my ($key) = each %$href) {
1086 0 0         if ($key =~ $re) {
1087 0           delete $href->{$key};
1088             }
1089             }
1090             }
1091              
1092             1;
1093             __END__