#! /usr/bin/perl -w # # yapfaq by Thomas Hochstein # (Original author: Marc Brockschmidt) # # containing some code from tinews.pl # Copyright (c) 2002-2024 Urs Janssen , # Marc Brockschmidt # containing some code from pgpverify.pl # Written April 1996, (David C Lawrence) # Currently maintained by Russ Allbery # # This script posts articles (e.g. FAQs) to Usenet newsgroups. # Most people will use it in combination with cron(8). # # Copyright (C) 2003 Marc Brockschmidt # Copyright (c) 2010-2017, 2026 Thomas Hochstein # # It can be redistributed and/or modified under the same terms under # which Perl itself is published. my $VERSION = "1.0.0"; (my $NAME = $0) =~ s#^.*/##; use utf8; use strict; use Encode qw(encode); use POSIX qw(strftime); use Net::Domain qw(hostfqdn); use Net::NNTP; use DateTime; # CPAN use Path::Tiny; # CPAN use Getopt::Long qw(GetOptions); Getopt::Long::config ('bundling'); use Data::Dumper; # configuration ####################### # may be overwritten via ~/.yapfaqrc or command line my %Config; $Config{'datadir'} = 'data/'; # path to data files (FAQs, ...) $Config{'nntp-server'} = 'news'; # your NNTP server name, may be set via $NNTPSERVER $Config{'nntp-port'} = 119; # NNTP-port, may be set via $NNTPPORT $Config{'nntp-user'} = ''; # username for AUTHINFO $Config{'nntp-pass'} = ''; # password for AUTHINFO $Config{'force-auth'} = 0; # set to 1 to force authentification $Config{'starttls'} = 0; # set to 1 to use STARTTLS if possible $Config{'verbose'} = 0; # set to 1 to get warning messages $Config{'debug'} = 0; # set to 1 to get some debug output, # set to 2 for NNTP debug output # Main program ######################## ### read configuration # from (first match counts) # $XDG_CONFIG_HOME/yapfaqrc # ~/.config/yapfaqrc # ~/.yapfaqrc # if present # taken and modified from tinews.pl my $RCFILE = undef; my (@try, %seen); if ($ENV{'XDG_CONFIG_HOME'}) { push(@try, (glob("$ENV{'XDG_CONFIG_HOME'}/yapfaqrc"))[0]); } push(@try, (glob('~/.config/yapfaqrc'))[0], (glob('~/.yapfaqrc'))[0]); foreach (grep { ! $seen{$_}++ } @try) { # uniq @try last if (open($RCFILE, '<', $_)); $RCFILE = undef; } if (defined($RCFILE)) { while (defined($_ = <$RCFILE>)) { if (m/^([^#\s=]+)\s*=\s*(\S[^#]+)/io) { chomp($Config{lc($1)} = $2); } } close($RCFILE); } # these env-vars have higher priority (order is important) # taken from tinews.pl $Config{'nntp-server'} = $ENV{'NEWSHOST'} if ($ENV{'NEWSHOST'}); $Config{'nntp-server'} = $ENV{'NNTPSERVER'} if ($ENV{'NNTPSERVER'}); $Config{'nntp-port'} = $ENV{'NNTPPORT'} if ($ENV{'NNTPPORT'}); ### read commandline options my ($OptProject,$OptForce,$OptTest,$OptNewsgroup,$OptOutput); GetOptions ('p|project=s' => \$OptProject, 'f|force' => \$OptForce, 't|test' => \$OptTest, 'n|newsgroup=s' => \$OptNewsgroup, 'o|output' => \$OptOutput, 'datadir=s' => \$Config{'datadir'}, 'nntp-server=s' => \$Config{'nntp-server'}, 'nntp-port=s' => \$Config{'nntp-port'}, 'nntp-user=s' => \$Config{'nntp-user'}, 'nntp-pass=s' => \$Config{'nntp-pass'}, 'starttls!' => \$Config{'starttls'}, 'force-auth!' => \$Config{'force-auth'}, 'v|verbose!' => \$Config{'verbose'}, 'd|debug!' => \$Config{'debug'}, 'c|config' => \&ShowConf, 'h|help' => \&ShowPOD, 'V|version' => \&ShowVersion) or &ShowUsage; ### create list of @Projects from $Config{'datadir'} unless -p is set my @Projects; if (!$OptProject) { die "E: Data directory '" . $Config{'datadir'} . "' does not exist.\n" unless (-d $Config{'datadir'}); @Projects = glob $Config{'datadir'} . '*.hdr'; foreach (@Projects) { $_ =~ s#^.*/##; $_ =~ s/\.hdr$//; } } else { push @Projects, $OptProject; } ### iterate over @Projects print "- Test mode, no status updates.\n" if $Config{'debug'}; foreach (@Projects) { # check for existence of project my $HeaderFile = $Config{'datadir'} . "$_.hdr"; if (not -r $HeaderFile) { warn "W: Project '$_' does not exist ('$HeaderFile' not found).\n"; next; } print "Project '$_' ...\n" if $Config{'verbose'} or $Config{'debug'}; # generate posting and check for due date # @Posting will be empty ('') if not due my @Posting = &BuildPosting($_); next if !$#Posting; # save Message-ID my $LastMID; foreach (@Posting) { if (/^Message-ID: /) { ($LastMID = $_) =~ s/^Message-ID:\s+//; chomp ($LastMID); last; } } # sent to STDOUT due to --output if ($OptOutput) { print "- Print to STDTOUT.\n----->----->----->-----\n" if $Config{'debug'}; foreach (@Posting) { print $_ }; print "-----<-----<-----<-----\n" if $Config{'debug'}; # otherwise: post } else { next if !&PostNNTP(@Posting); } # update status &UpdateStatus($_, $LastMID) if !$OptTest; } ### we're done exit(0); # subroutines ######################### ### ------------------------------------------------------------------ ### display version information and exit sub ShowVersion { print "$NAME v$VERSION\n"; print "Copyright (C) 2003 Marc Brockschmidt \n"; print "Copyright (c) 2010-2017, 2026 Thomas Hochstein \n"; print "This program is free software; you may redistribute it ". "and/or modify it under the same terms as Perl itself.\n"; exit(0); }; ### ------------------------------------------------------------------ ### feed myself to perldoc and exit sub ShowPOD { exec('perldoc', $0); exit(0); }; ### ------------------------------------------------------------------ ### Show current configuration sub ShowConf { print "$NAME v$VERSION\n"; print "Current configuration:\n"; foreach my $config (sort keys %Config) { printf("- %s: %s\n", $config, $Config{$config}) if $Config{$config}; } }; ### ------------------------------------------------------------------ ### display short usage information sub ShowUsage { print "$NAME v$VERSION\n"; print "Usage: " . $NAME . " [OPTIONS]\n"; print " -p project run on project only, don't use all projects\n"; print " -f post unconditionally, even if project(s) is/are not due\n"; print " -t don't update project status (test)\n"; print " -n newsgroup post only to newsgroup (for testing)\n"; print " -o print to STDOUT (for testing or to pipe into inews)\n"; print " --datadir path override \$datadir\n"; print " --nntp-server name override \$nntp-server\n"; print " --nntp-port port override \$nntp-port\n"; print " --nntp-user user override \$nntp-user\n"; print " --nntp-pass passwd override \$nntp-pass\n"; print " --[no-]starttls override \$starttls\n"; print " --[no-]force-auth override \$force-auth\n"; print " -v | --[no-]verbose override \$verbose\n"; print " -d | --[no-]debug override \$debug\n"; print " -c show current configuration\n"; print " -h show documentation\n"; print " -V show version and copyright\n"; exit 0; } ### ------------------------------------------------------------------ ### parse a YYYY-MM-DD construct to a DateTime object sub ParseDate { my $Date = shift; die "E: '$Date' is not a valid date format.\n" unless $Date =~ /^\d\d\d\d-\d\d-\d\d$/; my ($Year, $Month, $Day) = split /-/, $Date; return DateTime->new(year => $Year, month => $Month, day => $Day, ); } ### ------------------------------------------------------------------ ### add a duration (in d,w,m,y) to a DateTime object sub AddDuration { my($Date, $Duration) = @_; $Duration =~ /(\d+)(.)/; my ($Amount, $Timespan) = ($1, lc($2)); if ($Timespan eq 'd') { $Date->add(days => $Amount); } elsif ($Timespan eq 'w') { $Date->add(days => $Amount * 7); } elsif ($Timespan eq 'm') { $Date->add(months => $Amount); } elsif ($Timespan eq 'y') { $Date->add(years => $Amount); } return $Date; } ### ------------------------------------------------------------------ ### return a hash of all headers (ignoring duplicate headers) # taken and modified from tinews.pl sub ParseHeaders { my @Headers = @_; my (%Header, $Label, $Value); foreach (@Headers) { s/\r?\n$//; last if /^$/; if (/^(\S+):[ \t](.+)/) { ($Label, $Value) = ($1, $2); # discard all duplicate headers next if $Header{lc($Label)}; $Header{lc($Label)} = $Value; } elsif (/^\s/) { # continuation lines if ($Label) { s/^\s+/ /; $Header{lc($Label)} .= $_; } else { warn (sprintf("W: Non-header line: %s\n",$_)); } } else { warn (sprintf("W: Non-header line: %s\n",$_)); } } return %Header; }; ### ------------------------------------------------------------------ ### open NNTP connection, authenticate and return a Net::NNTP-Object # taken and modified from tinews.pl sub ConnectNNTP { my $NNTP = Net::NNTP->new( Host => $Config{'nntp-server'}, Reader => 1, Debug => $Config{'debug'}, Port => $Config{'nntp-port'}, SSL_verify_mode => 0, ) or die("E: Can't connect to ".$Config{'nntp-server'}.":".$Config{'nntp-port'}.".\n"); my $NNTPMsg = $NNTP->message(); my $NNTPCode = $NNTP->code(); if ($Config{'starttls'} && $NNTP->can_ssl()) { $NNTP->starttls; } if ($Config{'debug'}) { print '- Connected to ' . $NNTP->peerhost . ':' . $NNTP->peerport . "\n"; if ($Config{'starttls'}) { printf(" SSL-Fingerprint: %s %s\n", split(/\$/, $NNTP->get_fingerprint)); } } # no read and/or write access - give up if ($NNTPCode < 200 || $NNTPCode > 201) { $NNTP->quit(); } # read access - try to authenticate if ($NNTPCode == 201 || $Config{'force-auth'}) { # no user/password if (!$Config{'nntp-user'} || !$Config{'nntp-pass'}) { $NNTP->quit(); die('E: ' . $NNTPCode . ' ' . $NNTPMsg . "\n"); } $NNTP = &AuthNNTP($NNTP); } # try posting; on failure, try to authenticate $NNTP->post(); $NNTPCode = $NNTP->code(); if ($NNTPCode == 480) { $NNTP = &AuthNNTP($NNTP); $NNTP->post(); } return $NNTP; } ### ------------------------------------------------------------------ ### do AUTHINFO on a Net::NNTP-Object, die on failure # taken and modified from tinews.pl sub AuthNNTP { my $NNTP = shift; $NNTP->authinfo($Config{'nntp-user'}, $Config{'nntp-pass'}); my $NNTPMsg = $NNTP->message(); my $NNTPCode = $NNTP->code(); if ($NNTPCode != 281) { # auth failed $NNTP->quit(); die('E: ' . $NNTPCode . ' ' . $NNTPMsg . "\n"); } return $NNTP; } ### ------------------------------------------------------------------ ### build posting # read and parser header and body from files # read status file, check due date sub BuildPosting { my $Project = shift; my $StatusFile = $Config{'datadir'} . "$Project.cfg"; my $HeaderFile = $Config{'datadir'} . "$Project.hdr"; my $BodyFile = $Config{'datadir'} . "$Project.txt"; if (not -r $BodyFile) { warn "W: '$BodyFile' not found.\n"; return ''; } # read status file, if available my($LastPosted, $LastMID); if (-r $StatusFile) { print "- Reading status ($Project.cfg).\n" if $Config{'debug'}; my @Status = path($StatusFile)->lines_utf8; foreach (@Status) { # convert Windows line-endings to Unix s/\r//; if (/^Last-posted: /i) { chomp; ($LastPosted = $_) =~ s/^Last-posted:\s+//i; } elsif (/^Last-Message-ID: /i) { chomp; ($LastMID = $_) =~ s/^Last-Message-ID:\s+//i; } } } else { print "- No status file ($Project.cfg).\n" if $Config{'debug'}; } print "- Reading headers ($Project.hdr) and body ($Project.txt).\n" if $Config{'debug'}; my @Headers = path($HeaderFile)->lines_utf8; my @Body = path($BodyFile)->lines_utf8; my %Header = &ParseHeaders(@Headers); # check for mandatory headers if (!$Header{'from'} or !$Header{'subject'} or !$Header{'newsgroups'}) { warn "W: From, Subject or Newsgroups header missing from '$HeaderFile'.\n"; return ''; } # add Date: push @Headers, 'Date: ' . DateTime->now->strftime('%a, %d %b %Y %H:%M:%S %z') . "\n"; # add missing Message-ID: push @Headers, 'Message-ID: <%n-%y-%m-%d@' . hostfqdn. ">\n" if (!$Header{'message-id'}); # add User-Agent push @Headers, "User-Agent: $NAME/$VERSION\n"; # parse pseudo headers from body my ($InRealBody,$LastModified,$PostingFrequency); foreach (@Body) { # convert Windows line-endings to Unix s/\r//; next if $InRealBody; $InRealBody++ if /^$/; $LastModified = $1 if /^Last-modified:\s*(\S+)\s*$/i; $PostingFrequency = $1 if /^Posting-Frequency:\s*(\S+)\s*$/i; } # parse Posting-Frequency from pseudo-header if ($PostingFrequency) { print "- Posting-Frequency set to $PostingFrequency from pseudo-header.\n" if $Config{'debug'}; if ($PostingFrequency eq 'daily') { $PostingFrequency = '1d'; } elsif ($PostingFrequency eq 'weekly') { $PostingFrequency = '1w'; } elsif ($PostingFrequency =~ /bi-?weekly/) { $PostingFrequency = '2w'; } elsif ($PostingFrequency eq 'monthly') { $PostingFrequency = '1m'; } elsif ($PostingFrequency =~ /bi-?monthly/) { $PostingFrequency = '2m'; } } # parse placeholders in headers foreach (@Headers) { # convert Windows line-endings to Unix s/\r//; # drop empty header $_ = '' if /^$/; # Replace %LM placeholder in Subject: with the Last-modified: pseudo-header if (/^Subject: /) { if ($LastModified) { $_ =~ s/\%LM/$LastModified/g; } else { $_ =~ s/ ?[<\[{\(]?\%LM[>\]}\)]? ?//; } } # Replace placeholders in Message-ID: # %n project name # %y current year # %m current month # %d current day # %p PID if (/^Message-ID: /i) { my $TDY = DateTime->now->strftime('%Y'); my $TDM = DateTime->now->strftime('%m'); my $TDD = DateTime->now->strftime('%d'); $_ =~ s/\%n/$Project/g; $_ =~ s/\%y/$TDY/g; $_ =~ s/\%m/$TDM/g; $_ =~ s/\%d/$TDD/g; $_ =~ s/\%p/$$/g; # add random part in test mode if ($OptTest) { my $random = sprintf("%08X", rand(0xFFFFFFFF)); $_ =~ s/now,$_)->strftime('%a, %d %b %Y %H:%M:%S %z') . "\n"; } # add Supersedes: if set if (/^Supersedes: /) { if ($LastMID && !$OptTest) { $_= "Supersedes: $LastMID\n"; } else { $_ = ''; } } # overwrite Newsgroups: if --newsgroup is set if ($OptNewsgroup && /^Newsgroups: /) { print "- 'Newsgroups: $OptNewsgroup' has been set.\n" if $Config{'debug'}; $_= "Newsgroups: $OptNewsgroup\n"; } # get Posting-Frequency if (/^Posting-Frequency: /i) { chomp; $_ =~ s/^Posting-Frequency:\s+//i; $PostingFrequency = $_; $_ = ''; print "- Posting-Frequency set to $PostingFrequency.\n" if $Config{'debug'}; } } # not due if Posting-Freqency is "none" if ($PostingFrequency =~ /none/) { print "... is disabled.\n" if $Config{'verbose'} or $Config{'debug'}; return ''; } # default to 1 month if no (valid) Posting-Frequency is set $PostingFrequency = '1m' if $PostingFrequency !~ /^\d+[dwmy]$/; # check if posting is due print "- Posting has been forced.\n" if $Config{'debug'} && $OptForce; if ($OptForce or (!$LastPosted) or ($LastPosted && &AddDuration(&ParseDate($LastPosted),$PostingFrequency) <= DateTime->now)) { print "... is due and will be posted.\n" if $Config{'verbose'} or $Config{'debug'}; } else { print "... is not due.\n" if $Config{'verbose'} or $Config{'debug'}; return ''; } # return posting return @Headers, "\n", @Body; } ### ------------------------------------------------------------------ ### post via NNTP # taken and modified from tinews.pl sub PostNNTP { my @Posting = @_; my $NNTP = ConnectNNTP(); my $NNTPMsg = $NNTP->message(); my $NNTPCode = $NNTP->code(); print "- Post article.\n" if $Config{'debug'}; if ($NNTPCode == 340) { $NNTP->datasend(@Posting); ## buggy Net::Cmd < 2.31 $NNTP->set_status(200, ""); $NNTP->dataend(); $NNTPMsg = $NNTP->message(); $NNTPCode = $NNTP->code(); if (! $NNTP->ok()) { $NNTP->quit(); warn("W: Posting failed! Response from server:\n", $NNTPCode, ' ', $NNTPMsg); return 0; } } else { $NNTP->quit(); warn("W: Posting failed! Response from server:\n", $NNTPCode, ' ', $NNTPMsg); return 0; } $NNTP->quit(); print "- Done.\n" if $Config{'debug'}; return 1; } ### ------------------------------------------------------------------ ### update status (last posted, last mid) sub UpdateStatus { my ($Project, $LastMID) = @_; my $StatusFile = $Config{'datadir'} . "$Project.cfg"; my @Status; push @Status, "Last-Posted: " . DateTime->now->strftime('%Y-%m-%d') . "\n"; push @Status, "Last-Message-ID: $LastMID\n"; $StatusFile = path($StatusFile); $StatusFile->spew_utf8(@Status); print "- Status updated.\n" if $Config{'debug'}; return; }