#!/bin/sh #! -*- perl -*- eval 'exec perl -x -S $0 ${1+"$@"}' if 0; # Copyright (C) 2016-2021 Sergey Poznyakoff # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3, or (at your option) # any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . use strict; use warnings; use Getopt::Long qw(:config gnu_getopt no_ignore_case); use Pod::Usage; use Pod::Man; use LWP::UserAgent; use URI; use Unix::Sysexits; use File::Basename; use File::Temp qw(tempdir); use File::Path qw(remove_tree); use IPC::Open2; use Mail::Send; use Mail::Message; use Sys::Hostname; use POSIX qw(strftime); use Locale::PO; use Safe; my $progname = basename($0); my $progdescr = "Notifies translationproject.org about new POT file"; our $VERSION = "1.04"; my $keep; # Keep temporary directory on errors my $signature_file = "$ENV{HOME}/.signature"; # Signature file my $verbose; # Verbose mode my $dry_run; # Dry-run mode my %mailer_args; # Mailer arguments my @add_headers; # Additional headers my $refile_method; # Refiling method my $force_option; my $template = <<'EOT'; To: Subject: $package_base.pot Hello, The new $release_type version of $package_name is available at: $archive_url $signature EOT ; my $tp_url = q{http://translationproject.org/domain/${domain}.html}; my $pot_regex_str = q{The current template for this domain is }; my $wd; # Temporary working directory my %files; # Extracted files. Key - name under $topdir, value - pathname # within $wd # Package variables: these are shared with the Safe compartment in # expand_template. our $sender; # Sender email our $fullname; # Sender real name our $localdomain; # Local domain name our $recipient; # Override recipient address our $archive_url; # Tarball URL our $archive_file; # Archive file name our $topdir; # Toplevel directory from the archive our $package_name; # Package name; our $package_tarname; # Package archive name our $package_version; # Package version number our $package_base; # Package base name our $release_type; # Package type (alpha or stable) sub err { my $msg = shift; local %_ = @_; print STDERR "$progname: "; print STDERR "$_{prefix}: " if exists $_{prefix} && defined $_{prefix}; print STDERR "$msg\n"; } sub abend { my $code = shift; &err; if ($keep && $code ne EX_USAGE && $code ne EX_CONFIG) { err("examine $wd for details"); } exit($code); } sub info { print "$progname: "; print @_; print "\n"; } # download($source_url, dest => 'filename' or ref) # ------------------------------------------------ # Downloads material from $source. If 'dest' is a reference to a scalar, # the downloaded material is stored to that ref. If it is a filename, # the material is stored in the named disk file. If 'dest' is not given, # the name of the disk file is determined as the basename of the path # component from the $source_url. sub download { my ($source,%opt) = @_; my $url = new URI($source); my $dest = delete $opt{dest} || basename($url->path); my %args; if (ref($dest) eq '') { $args{':content_file'} = $dest; info("downloading $source to $wd/$dest") if $verbose; } else { info("downloading $source") if $verbose; } my $scheme = $url->scheme; eval { require "LWP/Protocol/$scheme.pm"; }; if ($@) { $@ =~ s/\s+at [^\s]+ line \d+\.$//; abend(EX_OSERR, "$@"); } my $ua = LWP::UserAgent->new(); $ua->agent("tpnotify/$VERSION"); my $response = $ua->get($url->as_string, %args); unless ($response->is_success) { abend(EX_UNAVAILABLE, "downloading $source failed: " . $response->status_line); } if (ref($dest) eq 'SCALAR') { $$dest = $response->decoded_content; } return $dest; } # get_sources($URL) # ----------------- # Download and extract source archive. sub get_sources { my ($source) = @_; $archive_file = download($source); info("scanning $archive_file") if $verbose; open(my $fd, '-|', "tar tf $archive_file") or abend(EX_NOINPUT, "can't open $archive_file: $!"); while (<$fd>) { chomp; unless (m#^(?.+?)/(?.*)$#) { abend(EX_DATAERR, "$archive_file content suspicious: member $_"); } if (defined($topdir)) { unless ($+{dir} eq $topdir) { abend(EX_DATAERR, "$archive_file content suspicious: $+{dir} does not match $topdir"); } } else { $topdir = $+{dir}; } my $f = $+{file}; if ($f eq 'configure.ac' || $f =~ m#po/.*\.pot#) { $files{$f} = $_; } } close $fd; info("top level directory: $topdir") if $verbose; # Verify available files unless (exists($files{'configure.ac'})) { abend(EX_DATAERR, "no configure.ac in $archive_file"); } unless (keys(%files) > 1) { abend(EX_DATAERR, "no potfile in $archive_file"); } my $filelist = join(' ', values(%files)); info("extracting from $archive_file") if $verbose; my $cmd = "tar xf $archive_file $filelist"; system($cmd); check_command_status($cmd); } # check_command_status($STAT) # --------------------------- # Handles the result of the system or wait function call. sub check_command_status { my $cmd = shift; my $status = shift || $?; if ($status == -1) { abend(EX_OSERR, "failed to run $cmd"); } elsif ($status & 127) { abend(EX_UNAVAILABLE, "$cmd exited on signal " . ($status & 127)); } elsif (my $e = ($status >> 8)) { abend(EX_UNAVAILABLE, "$cmd exited with status $e"); } } # verify # ------ # Verifies the tarball. Determines canonical package name, extracts the POT # file and checks if it lists the correct package name in its # "Project-Id-Version" header and that its msgids differ from the ones # already registered on the TP. sub verify { my ($in, $out); my $pid = open2($out, $in, "m4 -P - $files{'configure.ac'}") or abend(EX_NOINPUT, "can't open $files{'configure.ac'}: $!"); print $in <<'EOT'; m4_divert(-1) m4_changequote([,]) m4_define([AC_INIT],[m4_divert(0)$1 $2[]m4_divert(-1)]) EOT close $in; waitpid($pid, 0); check_command_status("m4"); chomp(my @lines = <$out>); abend(EX_DATAERR, "can't parse $files{'configure.ac'}") unless $#lines == 1; ($package_name, $package_version) = @lines; $package_tarname = $package_name; $package_tarname =~ s/GNU\s+//; $package_tarname = lc $package_tarname; # FIXME: this is not always right, # perhaps info("package $package_name, tarname $package_tarname, version $package_version") if $verbose; $package_base = "$package_tarname-$package_version"; unless (defined($release_type)) { if ($package_version =~ m/\d+\.\d+\.(\d+)/ && int($1) >= 90) { $release_type = 'alpha'; } else { $release_type = 'stable'; } } if (substr($archive_file, 0, length($package_base)) ne $package_base) { abend(EX_DATAERR, "filename $archive_file does not begin with $package_base"); } if ($package_base ne $topdir) { abend(EX_DATAERR, "toplevel directory $topdir does not begin with $package_base"); } my $potfile = "po/$package_tarname.pot"; unless ($files{$potfile}) { abend(EX_DATAERR, "potfile $potfile not found in archive"); } verify_potfile($files{$potfile}); } # po_header($FILENAME) # -------------------- # Extract the PO header from the POT file $FILENAME. # Returns a reference to a hash: header-name => value. sub po_header { my $name = shift; (my $h = Locale::PO->load_file_asarray($name)->[0]->msgstr) =~ s/^"(.*)"$/$1/; my %ret; foreach my $s (split /\\n/, $h) { if ($s =~ /^(.+?):\s*(.*)$/) { $ret{lc $1}=$2; } } \%ret; } # po_serialize($FILENAME) # ----------------------- # Serializes the pot file in the unambiguous way. # Extracts the msgids, sorts them lexicographically and concatenates them. sub po_serialize { my $name = shift; join("\n", sort map { ($_->msgid // '') . ':' . ($_->msgid_plural // '') } @{Locale::PO->load_file_asarray($name)}); } # po_cmp($A, $B) # -------------- # Compares two POT files. Returns 'true' if the two files contain exactly # the same set of msgids. sub po_cmp { my ($a,$b) = @_; po_serialize($a) eq po_serialize($b); } # verify_potfile($FILENAME) # ------------------------- # Verifies the potfile extracted from the archive. # Checks if the POT file mentions the correct package string in its # Project-Id-Version header. Downloads the POT file registered on the # TP and makes sure its msgids are not the same as defined in the POT # file from the archive. sub verify_potfile { my $potname = shift; my $hdr = po_header($potname); my $vs = $hdr->{'project-id-version'}; if ($vs ne "$package_name $package_version") { err("$potname: Project-Id-Version does not match \"$package_name $package_version\""); exit(EX_DATAERR) unless $force_option; } (my $url = $tp_url) =~ s/\$\{domain\}/$package_tarname/; download($url, dest => \my $content); if ($content =~ m{$pot_regex_str}) { my $tp_potname = download($1); if (po_cmp($potname, $tp_potname)) { err("potfile contains no new msgids; no need to upload"); exit(0) unless $force_option; } } } # Reads the signature file from $signature_file. sub read_signature { if (defined($signature_file)) { if (open(my $fd, '<', $signature_file)) { local $/; chomp(my $sig = <$fd>); close($fd); return $sig; } } return undef; } # Expands the message template. # Returns the expanded text. Abends on failure. sub expand_template { my $cpt = new Safe; $cpt->share(qw($sender $fullname $localdomain $recipient $archive_file $archive_url $package_name $package_version $package_base $release_type $topdir $signature)); ${$cpt->varglob('signature')} = read_signature; (my $tmpl = $template) =~ s/\@/\\\@/g; if ($cpt->reval("\$_ = qq{$tmpl}",1)) { return $_; } else { abend(EX_DATAERR, "while expanding template: $@"); } } # Reads the current value of the MH Path setting. sub read_mh_path { my $file = File::Spec->catfile($ENV{HOME}, '.mh_profile'); if (-f $file) { if (open(my $fd, '<', $file)) { my $prev; while (<$fd>) { chomp; if (s/^\s+//) { $prev .= ' ' . $_; } else { last if defined($prev) && $prev =~ /^Path:/; $prev = $_; } } close $fd; return $prev if $prev =~ s/^Path:\s+//; } else { err("can't open $file: $!", prefix => 'warning'); } } return "$ENV{HOME}/Mail"; } sub notify { my $msg = Mail::Message->read(expand_template); $msg->head()->add('From', "\"$fullname\" <$sender>") unless $msg->get('From'); foreach my $hdr (@add_headers) { $msg->head()->add($hdr); } $msg->head()->add("X-Mailer: $progname $VERSION"); if ($verbose) { info("message to send"); $msg->print(); print "\n"; info("end of message"); } if ($dry_run) { info("NOT sending"); return; } info("sending message") if $verbose; if (my $folder = $msg->get('Fcc')) { $msg->head()->delete('Fcc'); refile($msg, $folder); } $msg->send(%mailer_args); # my $to = $recipient || $msg->get('To'); # info("Location of $package_base.pot sent to $to"); } sub mail_box_manager_supported { eval { require Mail::Box::Manager; }; return !$@; } sub mail_box_manager_refile { my ($msg,$folder) = @_; my %args = (create => 1, access => 'rw'); if ($folder =~ m#^/#) { $args{type} = 'mbox'; $args{folder} = $folder; } elsif ($folder =~ s#mh:(?://)?(.+)#$1#) { $args{type} = 'mh'; if ($folder =~ m#^/#) { $args{folder} = $folder; } else { $args{folder} = read_mh_path() . '/' . $folder; } } elsif ($folder =~ s#maildir:(?://)?(.+)#$1#) { $args{type} = 'maildir'; $args{folder} = $folder; } else { abend(EX_DATAERR, "unrecognized Fcc folder: $folder"); } my $mgr = Mail::Box::Manager->new(); my $fld = $mgr->open(%args) or abend(EX_CANTCREAT, "can't open folder $folder"); $fld->addMessage($msg) or abend(EX_CANTCREAT, "can't save message to folder $folder"); $fld->close; } sub movemail_supported { if (open(my $fd, '-|', 'movemail --version')) { chomp($_ = <$fd>); if (m{^movemail \(GNU Mailutils\) (?\d+)\.(?\d+)}) { return 1; } } } sub movemail_refile { my ($msg,$folder) = @_; my $tmpname = "message"; open(my $fd, '>', $tmpname) or abend(EX_CANTCREAT, "can't create temporary file: $!"); my $sender_address = ($msg->study('From')->addresses())[0]->address(); print $fd "From $sender_address " . strftime("%a %b %e %H:%M:%S %Y", gmtime) . "\n"; $msg->write($fd); close($fd); if ($folder =~ m{mh:(?://)?(.+)}) { my $dir = $1; if (!File::Spec->file_name_is_absolute($dir) && $dir !~ '^~') { if (my $path = read_mh_path()) { $folder = File::Spec->catfile($path, $dir); } else { err("no MH Path: assuming mh://$dir", prefix => 'warning'); } $folder = 'mh://' . $folder; } } system("movemail $wd/$tmpname $folder"); unlink $tmpname; } my %refile_tab = ( 'movemail' => { supported => \&movemail_supported, refile => \&movemail_refile, priority => 10 }, 'perl' => { supported => \&mail_box_manager_supported, refile => \&mail_box_manager_refile, priority => 0 } ); sub refile { unless ($refile_method) { foreach my $meth (sort { $refile_tab{$a}{priority} <=> $refile_tab{$b}{priority} } keys %refile_tab) { if (&{$refile_tab{$meth}{supported}}) { $refile_method = $meth; last; } } unless ($refile_method) { err("no method is available to refile the message"); return; } } info("using $refile_method for refiling"); &{$refile_tab{$refile_method}{refile}}(@_); } END { chdir("/"); if (!($? && $keep)) { remove_tree($wd, {error => \my $err}); if (@$err) { err("errors removing $wd:"); for my $diag (@$err) { my ($file, $message) = %$diag; if ($file eq '') { err($message); } else { err("$file: $message"); } } } } } sub set_mailer { my ($mailer, $locus) = @_; if ($mailer =~ /sendmail:(.*)/) { $mailer_args{via} = 'sendmail'; $mailer_args{executable} = $1 if $1; } elsif ($mailer =~ m#smtp://(?:(?[^:]+)(?::(?.+))?@)?(?[^:]+)(?::(?\d+))?#) { $mailer_args{via} = 'smtp'; $mailer_args{hostname} = $+{host}; $mailer_args{port} = $+{port} if $+{port}; $mailer_args{username} = $+{user} if $+{user}; $mailer_args{password} = $+{password} if $+{password}; } else { err("unknown mailer spec", prefix => $locus); return 0; } return 1; } sub read_template_file { my ($file, $locus) = @_; if (open(my $fd, '<', $file)) { local $/; $template = <$fd>; close($fd); return 1; } else { err("can't open template file $file: $!", prefix => $locus); return 0; } } my %kw = ( keep => \$keep, 'template-file' => \&read_template_file, template => \$template, 'signature-file' => \$signature_file, mailer => \&set_mailer, from => \$sender, sender => \$sender, fullname => \$fullname, domain => \$localdomain, to => \$recipient, add => \@add_headers, 'add-header' => \@add_headers, 'refile-method' => \$refile_method ); sub read_config { my $config_file = shift; open(FILE, "<", $config_file) or abend(EX_NOINPUT, "cannot open $config_file: $!"); my $line = 0; my $err; my $key; my $val; my $heredoc; my $heredoc_line; while () { ++$line; if ($heredoc) { if (/^$heredoc\s*$/) { $heredoc = undef; } else { $val .= $_; next; } } else { chomp; s/^\s+//; s/\s+$//; s/#.*//; next if ($_ eq ""); if (/^(.*?)\s*=\s*(.*)/) { $key = $1; $val = $2; if ($val =~ /<<(\w+)\s*$/) { $heredoc = $1; $heredoc_line = $line; $val = ''; next; } } else { err("$config_file:$line: syntax error"); ++$err; } } if (exists($kw{$key})) { my $ref = $kw{$key}; if (ref($ref) eq 'CODE') { unless (&{$ref}($val, "$config_file:$line")) { ++$err; } } elsif (ref($ref) eq 'ARRAY') { push @{$ref}, $val; } else { $$ref = $val; } } else { err("$config_file:$line: unrecognized keyword: '$key'"); ++$err; } } close FILE; abend(EX_CONFIG, "unfinished heredoc, started at line $heredoc_line") if defined $heredoc; abend(EX_CONFIG, "errors in config file") if $err; } # my $debug; my $config_file = "$ENV{HOME}/.tpnotify" if -e "$ENV{HOME}/.tpnotify"; Getopt::Long::Configure(qw(gnu_getopt no_ignore_case pass_through)); GetOptions("help" => sub { pod2usage(-exitstatus => EX_OK, -verbose => 2); }, "h" => sub { pod2usage(-message => "$progname: $progdescr", -exitstatus => EX_OK); }, "usage" => sub { pod2usage(-exitstatus => EX_OK, -verbose => 0); }, "config|c=s" => \$config_file, "no-config|N" => sub { $config_file = undef }, ); read_config($config_file) if defined $config_file; Getopt::Long::Configure(qw(gnu_getopt no_ignore_case no_pass_through)); GetOptions("keep|k" => \$keep, "alpha|A" => sub { $release_type = 'alpha' }, "stable|S" => sub { $release_type = 'stable' }, "template|t=s" => sub { exit(EX_NOINPUT) unless read_template_file($_[1]) }, "signature|s=s" => \$signature_file, "no-signature" => sub { $signature_file = undef }, "verbose|v+" => \$verbose, "dry-run|n" => \$dry_run, "debug|d+" => \$debug, "mailer|m=s" => sub { exit(EX_USAGE) unless set_mailer($_[1]) }, "from|f=s" => \$sender, "fullname|F=s" => \$fullname, "domain|D=s" => \$localdomain, "to=s" => \$recipient, "add|a=s@" => \@add_headers, "refile-method=s" => \$refile_method, "force" => \$force_option ) or exit(EX_USAGE); ++$verbose if $dry_run; if ($debug && exists($mailer_args{via})) { if ($mailer_args{via} eq 'sendmail') { $mailer_args{sendmail_options} = [] unless exists $mailer_args{sendmail_options}; push @{$mailer_args{sendmail_options}}, '-O', 'LogLevel=99', '-d10.100', '-d13.90', '-d11.100'; } elsif ($mailer_args{via} eq 'smtp') { $mailer_args{smtp_debug} = 1; } } if ($sender && exists($mailer_args{via})) { if ($mailer_args{via} eq 'sendmail') { $mailer_args{sendmail_options} = [] unless exists $mailer_args{sendmail_options}; push @{$mailer_args{sendmail_options}}, '-f', $sender; } elsif ($mailer_args{via} eq 'smtp') { $mailer_args{from} = $sender; } } my ($name,undef,undef,undef,undef,$comment,$gecos) = getpwuid($<); $fullname = $gecos || $comment || $name unless defined $fullname; $sender = $name . '@' . ($localdomain || hostname()) unless defined $sender; if ($recipient) { $mailer_args{to} = $recipient; } $archive_url = shift; abend(EX_USAGE, "not enough arguments") unless defined $archive_url; abend(EX_USAGE, "too many arguments") unless $#ARGV == -1; if ($refile_method) { abend(EX_USAGE, "unknown refiling method") unless exists($refile_tab{$refile_method}); abend(EX_USAGE, "refiling method not supported") unless (&{$refile_tab{$refile_method}{supported}}); } $wd = tempdir() or abend(EX_CANTCREAT, "can't create temporary directory: $!"); chdir($wd) or abend(EX_OSERR, "can't change to temporary directory $wd: $!"); get_sources($archive_url); verify; notify; __END__ =head1 NAME tpnotify - Notifies translationproject.org about new POT file =head1 SYNOPSIS B [B<-ANSdnkv>] [B<-D> I] [B<-F> I] [B<-a> IB<:>I [B<-c> I] [B<-f> I] [B<-m> I] [B<-s> I] [B<-t> I] [B<--add=>I:I] [B<--alpha>] [B<--config=>I] [B<--debug>] [B<--domain=>I] [B<--dry-run>] [B<--force>] [B<--from=>I] [B<--fullname=>I] [B<--keep>] [B<--mailer=>I] [B<--no-config>] [B<--no-signature>] [B<--refile-method=>B | B] [B<--signature=>I] [B<--stable>] [B<--template=>I] [B<--to=>I] [B<--verbose>] I B [B<-h>] [B<--help>] [B<--usage>] =head1 DESCRIPTION Notifies the coordinator of the I about new POT file available at I. The URL must point to a tarball of a package registered at TP (I). The tool works as follows: First of all, the indicated I is downloaded to a temporary location on disk. The contents of the retrieved tarball is inspected. It must contain the file F in the project top level directory and one or more files with the B<.pot> suffix in the F subdirectory. These files are extracted. The F is parsed in order to determine the package name and version (from the B statement). The canonical package name is formed by concatenating the package name (with the eventual B prefix stripped), a dash, and the version number. The name of the POT file is constructed by appending the B<.pot> suffix to the base name, This file is looked up in the B subdirectory. When this initial stage is through, the message template is expanded. See the B