47eece63c6a718337c62ce0ab555da8e3b363975
[mir.git] / scripts / mirror-scripts / update.pl
1 #!/usr/bin/perl -w
2
3 # This script is to get the changes needed to update the 
4 # files on a mir mirror.
5 #
6 # Full details (among other stuff) at
7 # http://lists.indymedia.org/pipermail/imc-uk-tech/2006-May/0531-ib.html
8 #
9 # An extract is
10 #
11 # Mir will simply publish a list of file changes.  It will produce one
12 # file per day.  The file will be publicly viewable on the web at
13 #
14 # /changes/changesYYYYMMDD.txt
15
16 # So on May 28th, 2006, the file name would be
17 #
18 # /changes/changes20060528.txt
19
20 # The format of the file will also be simple.  There will be three
21 # columns.  First will be the time, then (add/change/delete) depending
22 # whether the file is new, has been updated or deleted, and then the full
23 # relative path of the file.  So an example line would be
24
25 # 2006:01:24T09:08:17   add   /en/2006/05/341547.html
26 #
27 # This will have to be done for HTML files, media files and the include
28 # files.
29
30 use strict;
31
32 use Date::Calc::Object;   # For date/time math
33 use File::stat;           # For getting mtimes
34 use Getopt::Long;         # For parsing command-line options
35 use HTTP::Status;         # For HTTP status codes
36 use LWP::UserAgent;       # For HTTP client functionality
37 use HTTP::Date;           # For HTTP-compatible str2time and time2str
38
39
40 ##################
41 # Global variables
42 ##################
43
44 # Verbose? (Debugging messages)
45 my $verbose = 0;
46
47 # by default, this is the working directory
48 my $workingdir = ".";
49
50 # get the time and date - using UTC
51 my $timenow = time();
52
53 # if this is 1, we only list the change files that are needed and stop
54 # otherwise we do our normal processing
55 my $listchangefiles = 0;
56
57 # The root URL to fetch files from
58 my $remoteroot;
59
60 ################################
61 # process command line arguments
62 ################################
63
64 sub usage() {
65   print STDERR <<EOF
66 Usage: update.pl [options]
67
68   --remoteroot=url       Set the remote root URL to mirror from (mandatory)
69   --workingdir=path      Set the root of the local mirror (default ".")
70   --now=timestring       Pretend it's currently the specified time
71   --list-change-files    Just list the change files we would fetch
72   --verbose              Run in verbose mode
73   --help                 Output this help
74
75 EOF
76   ;
77   exit 1;
78 }
79
80 GetOptions( "verbose!" => \$verbose,
81             "workingdir=s" => \$workingdir,
82             "now=s" => sub($) { $timenow = str2time($_[0]); },
83             "remoteroot=s" => \$remoteroot,
84             "list-change-files!" => \$listchangefiles,
85             "help" => sub() { usage(); } );
86
87 usage() if not defined $remoteroot;
88
89 # the directory where all files with state are kept
90 my $statedir = "$workingdir/state";
91 if (! -e $statedir) {
92         mkdir $statedir or die "Can't create $statedir";
93 }
94
95 # the directory where changes files are kept
96 my $changesdir = "$workingdir/changes";
97
98 # the file we want with the last time in it is
99 my $lastupdatefile = "$statedir/lastupdate.txt";
100
101 # where we get changes files from
102 my $changesroot = "$remoteroot/changes";
103
104 # LWP user agent for fetching files
105 # keep_alive is important, to avoid the overhead of
106 # establishing a new connection for each file we fetch
107 my $ua = new LWP::UserAgent( agent => "MirMirror/0.1",
108                              keep_alive => 1 );
109
110
111 ###############
112 # SUBROUTINES #
113 ###############
114
115 # find the last update time
116 # if the file with the update time has disappeared, alert the admin
117 # and use the datestamp on the startpage file ( /en/index.html )
118 sub findLastUpdateTime() {
119   open (UPDATETIME, "<", $lastupdatefile) or return Date::Calc->today()->date2time();
120   my $lastupdatetimestr = <UPDATETIME>;
121   close (UPDATETIME);
122   
123   chomp ($lastupdatetimestr);
124   my $lastupdatetime = str2time ($lastupdatetimestr);
125   die "Can't parse last update time" if not $lastupdatetime;
126   return str2time ($lastupdatetimestr);
127 }
128
129 # write the time now into the last update file
130 sub saveLastUpdateTime() {
131   open (UPDATETIME, ">", $lastupdatefile) or die "Can't open $lastupdatefile for writing ($!)";
132   print UPDATETIME time2str($timenow); 
133   close (UPDATETIME);
134 }
135
136 # return an array of filename 
137 sub getChangesFileList($$)
138 {
139   my ($fromtime, $totime) = @_;
140   my @files;
141   my $time = Date::Calc->time2date($fromtime);
142   my $maxtime = Date::Calc->time2date($totime);
143   for (; $time <= $maxtime; ++$time) {
144     my ($y,$m,$d) = $time->date;
145     push @files, sprintf("changes%04d%02d%02d.txt",$y,$m,$d);
146   }
147   return @files;
148 }
149
150 # get the directory part of a filename
151 sub dirPart($) {
152   my $dir = $_[0];
153   $dir =~ s{/+[^/]*$}{};
154   return $dir;
155 }
156
157 # ensure the given directory exists (like mkdir -p)
158 sub ensureDir($) {
159   my $dir = $_[0];
160   if (! -e $dir) {
161     my $parent = dirPart($dir);
162     &ensureDir($parent) if ($parent);
163     mkdir $dir or die "Can't create directory $dir ($!)";
164   }
165 }
166
167 # get a file, optionally saving it locally.
168 #   if a local filename is given, return true if the file was
169 #   found on the server and undef otherwise
170 #
171 #   if no local filename is given, return the content of the
172 #   file, or undef if it was not found on the server
173 #
174 #   on all other errors, die
175 sub fetchFile($;$) {
176   my ($remotefile, $localfile) = @_;
177
178   if ($verbose) {
179     print STDERR "fetching $remotefile";
180     print STDERR " as $localfile" if $localfile;
181   }
182
183   my $req = new HTTP::Request(GET => "$remotefile");
184
185   if ($localfile) {
186     if (-e $localfile) {
187       # Don't fetch unless more recent than local copy
188       my $stat = stat($localfile);
189       $req->header("If-Modified-Since" => time2str($stat->mtime));
190     }
191     else {
192       ensureDir(dirPart($localfile));
193     }
194   }
195   my $resp = $ua->request($req, $localfile);
196   if ($resp->is_success) { # 2xx codes
197     my $mtime = str2time($resp->header("Last-Modified"));
198     if ($verbose) {
199       print STDERR " -> success";
200       print STDERR "; mtime ".time2str($mtime) if $mtime;
201       print STDERR "\n";
202     }
203
204     if ($localfile and $mtime) {
205       utime $mtime, $mtime, $localfile;
206     }
207     return $localfile ? 1 : $resp->content;
208   }
209   elsif ($resp->is_redirect) { # 3xx codes
210     if ($resp->code == RC_NOT_MODIFIED) { # 304
211       print STDERR " -> not modified\n" if $verbose;
212       die "Got 304 with no local file" if not $localfile;
213       return 1;
214     }
215     print STDERR " -> redirect (".$resp->code.")\n" if $verbose;
216     die "Can't fetch $remotefile (got redirect, not yet handled)";
217   }
218   else {
219     if ($resp->code == RC_NOT_FOUND) { # 404
220       print STDERR " -> not found\n" if $verbose;
221       return undef;
222     }
223
224     print STDERR " -> failed (".$resp->code.")\n" if $verbose;
225     die "Can't fetch $remotefile (".$resp->status_line.")";
226   }
227 }
228
229 # get a changes file
230 sub getChangesFile($) {
231   return fetchFile($changesroot."/".$_[0], $changesdir."/".$_[0]);
232 }
233
234
235
236 ##################
237 # the program flow
238 ##################
239
240 # first work out when the last time we were up to date is and 
241 # find present time.
242 my $timeoflastupdate = findLastUpdateTime();
243
244 if ($verbose) {
245   print STDERR "timenow          is ".time2str($timenow)." \n";
246   print STDERR "timeoflastupdate is ".time2str($timeoflastupdate)." \n\n";
247 }
248
249 # Now we know which days' changes we need to get from the server
250 my @changesfiles = getChangesFileList($timeoflastupdate, $timenow);
251 if ($verbose) {
252   foreach my $file (@changesfiles) { print STDERR "using changes file $file\n"; }
253 }
254
255 if ($listchangefiles) {
256   foreach my $file (@changesfiles) { print "$file\n"; }
257   exit 0;
258 }
259
260 # get the changes files
261 foreach my $file (@changesfiles) { getChangesFile($file); }
262
263 # TODO: if the file has not changed (response code 304) then ignore it
264
265 # iterate over all the fetched files, building up a list of files
266 # to fetch/delete
267 my %files;
268 foreach my $changesfile (@changesfiles) {
269   if (! -e "$changesdir/$changesfile") {
270     print STDERR "Skipping changes file $changesfile; not present\n" if $verbose;
271     next;
272   }
273
274   my $date = $changesfile;
275   $date =~ s{^(?:.*/)?changes([0-9]+)\.txt$}{$1} or die "Can't extract date from changes filename $changesfile";
276
277   print STDERR "Processing changes file $changesfile\n" if $verbose;
278
279   open (CHANGES, "<", "$changesdir/$changesfile") or die "Can't open $changesfile";
280   while (my $change = <CHANGES>) {
281     my ($time, $op, $path) = split ' ', $change;
282
283     # Ignore malformed lines, especially wacky paths that could be malicious
284     if ($time =~ /[^0-9:]/) {
285       die "Invalid time $time in $changesfile";
286     }
287
288     if ($path =~ m{(?:^|/)\.\.(?:/|$)}) {
289       die "Invalid path $path (contains ..) in $changesfile";
290     }
291
292     # Strip scheme and host from absolute URLs
293     $path =~ s{^[a-z]+://[a-z0-9\-\.]+/}{/};
294
295     # Combine time with date and parse
296     $time = str2time("$date $time");
297     if (not defined $time) {
298       die "Failed to parse datetime '$date $time'";
299     }
300
301     # Ignore changes prior to $timeoflastupdate
302     next if $time < $timeoflastupdate;
303
304     $files{$path} = $op;
305     print STDERR "Marked $path as '$op'\n" if $verbose;
306   }
307 }
308
309 # Fetch all files whose last operation was "add" or "change"
310 # Delete all files whose last operation was "delete"
311 while (my ($file, $op) = each %files) {
312   if ($op eq "delete") {
313     if (-e "$workingdir/$file") {
314       # delete: if the file exists, remove it
315       print STDERR "deleting $workingdir/$file\n" if $verbose;
316       unlink "$workingdir/$file" or die "Can't delete $workingdir/$file ($!)";
317     }
318     else {
319       print STDERR "not deleting $workingdir/$file beacuse it doesn't exist\n" if $verbose;
320     }
321   }
322   elsif ($op eq "add" or $op eq "change" or $op eq "Modification") {
323     # add/change: re-fetch the file
324     fetchFile("$remoteroot/$file","$workingdir/$file") or die "File $remoteroot/$file not found";
325   }
326   else {
327     die "Unknown operation '$op'";
328   }
329 }
330
331
332 # update the last "up-to-date" time
333 saveLastUpdateTime();
334
335 # finish
336 exit 0;
337
338