forked from HPR/hpr-tools
		
	
		
			
	
	
		
			2344 lines
		
	
	
		
			77 KiB
		
	
	
	
		
			Plaintext
		
	
	
	
	
	
		
		
			
		
	
	
			2344 lines
		
	
	
		
			77 KiB
		
	
	
	
		
			Plaintext
		
	
	
	
	
	
|   | #!/usr/bin/env perl | ||
|  | #=============================================================================== | ||
|  | # | ||
|  | #         FILE: process_comments | ||
|  | # | ||
|  | #        USAGE: ./process_comments [-help] [-docmentation|manpage] [-debug=N] | ||
|  | #               [-[no]dry-run] [-verbose ...] [-[no]verify] [-[no]live] | ||
|  | #               [-[no]json] [-config=FILE] | ||
|  | # | ||
|  | #  DESCRIPTION: Read new comments received as mail messages (or as JSON files) | ||
|  | #               and perform moderation actions on them before possibly adding | ||
|  | #               them to the HPR database. | ||
|  | # | ||
|  | #      OPTIONS: --- | ||
|  | # REQUIREMENTS: --- | ||
|  | #         BUGS: --- | ||
|  | #        NOTES: 2023-12-23 enabled the experimental::try feature and removed | ||
|  | #               the use of 'Try::Tiny'. Revert by removing "use feature 'try'" | ||
|  | #               and 'no warnings "experimental::try"'. Remove the '($e)' from | ||
|  | #               each 'catch' and end the 'catch' braces with a semicolon. Add | ||
|  | #               back 'use Try::Tiny'. | ||
|  | # | ||
|  | #       AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com | ||
|  | #      VERSION: 0.3.3 | ||
|  | #      CREATED: 2017-09-04 18:06:49 | ||
|  | #     REVISION: 2024-01-10 13:02:51 | ||
|  | # | ||
|  | #=============================================================================== | ||
|  | 
 | ||
|  | use v5.36; # say and try/catch | ||
|  | use feature 'try'; | ||
|  | use strict; | ||
|  | use warnings; | ||
|  | no warnings "experimental::try"; | ||
|  | use utf8; | ||
|  | 
 | ||
|  | use Carp; | ||
|  | use Getopt::Long; | ||
|  | use Pod::Usage; | ||
|  | 
 | ||
|  | use Config::General; | ||
|  | use List::Util qw{max}; | ||
|  | use HTML::Parser; | ||
|  | use HTML::Restrict; | ||
|  | use HTML::Entities; | ||
|  | use File::Find::Rule; | ||
|  | use File::Copy; | ||
|  | use File::Slurper 'read_text'; | ||
|  | #use IO::All; | ||
|  | use JSON; | ||
|  | 
 | ||
|  | use Mail::Address; | ||
|  | use Mail::Field; | ||
|  | use Mail::Internet; | ||
|  | use MIME::Parser; | ||
|  | use Encode qw(decode encode); | ||
|  | 
 | ||
|  | use DateTime::Format::ISO8601; | ||
|  | use IO::Prompter; | ||
|  | use Template; | ||
|  | use SQL::Abstract; | ||
|  | use LWP::UserAgent; | ||
|  | 
 | ||
|  | #use TryCatch; # Failing 2020-05-31 due to a problem in Devel::Declare | ||
|  | #use Try::Tiny; # Using in-built experimental try/catch | ||
|  | use Log::Handler; | ||
|  | 
 | ||
|  | use DBI; | ||
|  | 
 | ||
|  | use Data::Dumper; | ||
|  | 
 | ||
|  | # | ||
|  | # Version number (manually incremented) | ||
|  | # | ||
|  | our $VERSION = '0.3.3'; | ||
|  | 
 | ||
|  | # | ||
|  | # Script and directory names | ||
|  | # | ||
|  | ( my $PROG = $0 ) =~ s|.*/||mx; | ||
|  | ( my $DIR  = $0 ) =~ s|/?[^/]*$||mx; | ||
|  | $DIR = '.' unless $DIR; | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Declarations | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # | ||
|  | # Constants and other declarations | ||
|  | # | ||
|  | my $basedir    = "$ENV{HOME}/HPR/Comment_system"; | ||
|  | my $configfile = "$basedir/.${PROG}.cfg"; | ||
|  | 
 | ||
|  | my ( $dbh, $sth1, $h1 ); | ||
|  | 
 | ||
|  | my ( @ignore, @files ); | ||
|  | my ($comment, %updates, $shortfile); | ||
|  | 
 | ||
|  | # | ||
|  | # The MIME part we're looking for | ||
|  | # | ||
|  | my $target_mimetype = 'application/json'; | ||
|  | 
 | ||
|  | # | ||
|  | # All of the keys in the JSON we're interested in (when checking and adding to | ||
|  | # the database) | ||
|  | # | ||
|  | my @json_keys = ( | ||
|  |     "eps_id",              "comment_timestamp", | ||
|  |     "comment_author_name", "comment_title", | ||
|  |     "comment_text", | ||
|  | ); | ||
|  | 
 | ||
|  | # | ||
|  | # Keys we need to clean | ||
|  | # | ||
|  | my @cleanable_keys = ( | ||
|  |     "comment_author_name", "comment_title", | ||
|  |     "comment_text", | ||
|  | ); | ||
|  | 
 | ||
|  | # | ||
|  | # Enable Unicode mode | ||
|  | # | ||
|  | binmode STDOUT, ":encoding(UTF-8)"; | ||
|  | binmode STDERR, ":encoding(UTF-8)"; | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Options and arguments | ||
|  | #------------------------------------------------------------------------------- | ||
|  | my $DEF_DEBUG = 0; | ||
|  | my $DEF_LIVE  = 0;                              # Gets edited on borg | ||
|  | 
 | ||
|  | # | ||
|  | # Process options | ||
|  | # | ||
|  | my %options; | ||
|  | Options( \%options ); | ||
|  | 
 | ||
|  | # | ||
|  | # Default help | ||
|  | # | ||
|  | pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 ) | ||
|  |     if ( $options{'help'} ); | ||
|  | 
 | ||
|  | # | ||
|  | # Full documentation if requested with -doc | ||
|  | # | ||
|  | pod2usage( | ||
|  |     -msg       => "$PROG version $VERSION\n", | ||
|  |     -verbose   => 2, | ||
|  |     -exitval   => 1, | ||
|  |     -noperldoc => 0, | ||
|  | ) if ( $options{'documentation'} ); | ||
|  | 
 | ||
|  | # | ||
|  | # Collect options | ||
|  | # | ||
|  | my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG ); | ||
|  | my $cfgfile | ||
|  |     = ( defined( $options{config} ) ? $options{config} : $configfile ); | ||
|  | my $live     = ( defined( $options{live} )   ? $options{live}   : $DEF_LIVE ); | ||
|  | my $verify   = ( defined( $options{verify} ) ? $options{verify} : 0 ); | ||
|  | my $dry_run  = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 ); | ||
|  | my $verbose  = ( defined( $options{verbose} )   ? $options{verbose}   : 0 ); | ||
|  | my $jsonmode = ( defined( $options{json} )      ? $options{json}      : 0 ); | ||
|  | 
 | ||
|  | # | ||
|  | # Report on the options if requested | ||
|  | # | ||
|  | if ($DEBUG > 0) { | ||
|  |     _debug(1, 'Options'); | ||
|  |     _debug(1, | ||
|  |         '-config=' . $cfgfile, | ||
|  |         ($live eq 0 ? '-nolive ' : '-live'), | ||
|  |         ($verify eq 0 ? '-noverify ' : '-verify'), | ||
|  |         ($dry_run eq 0 ? '-nodry-run ' : '-dry-run'), | ||
|  |         '-verbose=[' . $verbose . ']', | ||
|  |         ($jsonmode eq 0 ? '-nojson ' : '-json'), | ||
|  |     ); | ||
|  |     _debug(1, ''); | ||
|  | } | ||
|  | 
 | ||
|  | # | ||
|  | # Sanity checks | ||
|  | # | ||
|  | die "Unable to find configuration file: $cfgfile\n" unless ( -e $cfgfile ); | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Configuration file - load data | ||
|  | #------------------------------------------------------------------------------- | ||
|  | my $conf = Config::General->new( | ||
|  |     -ConfigFile      => $cfgfile, | ||
|  |     -InterPolateVars => 1, | ||
|  |     -InterPolateEnv  => 1, | ||
|  |     -ExtendedAccess  => 1, | ||
|  | ); | ||
|  | my %config = $conf->getall(); | ||
|  | 
 | ||
|  | _debug($DEBUG > 2, 'Configuration settings:', Dumper(\%config)); | ||
|  | 
 | ||
|  | my $logfile    = $config{settings}->{logfile}; | ||
|  | my $template   = $config{settings}->{template}; | ||
|  | 
 | ||
|  | my $maildrop   = $config{settings}->{maildrop}; | ||
|  | my $processed  = $config{settings}->{processed}; | ||
|  | my $rejected   = $config{settings}->{rejected}; | ||
|  | my $banned     = $config{settings}->{banned}; | ||
|  | 
 | ||
|  | my $jsondir    = $config{settings}->{jsondir}; | ||
|  | my $jprocessed = $config{settings}->{jprocessed}; | ||
|  | my $jrejected  = $config{settings}->{jrejected}; | ||
|  | my $jbanned    = $config{settings}->{jbanned}; | ||
|  | 
 | ||
|  | my $callback_template = $config{settings}->{callback_template}; | ||
|  | 
 | ||
|  | my ($cb_user, $cb_password) = split(":",$config{cms}->{user}); | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Moderation responses and the arguments associated with them (from the | ||
|  | # configuration data). | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # | ||
|  | # key => [ 3 element array ] | ||
|  | # | ||
|  | # - key is the menu element from do_moderation | ||
|  | # - the array contains: | ||
|  | #       - text to be used in messages | ||
|  | #       - anonymous subroutine which returns the directory into which to place | ||
|  | #         the file; call it with the $jsonmode variable | ||
|  | #       - action keyword to send back to the server | ||
|  | # | ||
|  | my %moderation_keys = ( | ||
|  |     'Approve' => [ | ||
|  |         'processed', | ||
|  |         sub { ( $_[0] ? $jprocessed : $processed ) }, | ||
|  |         'approve' | ||
|  |     ], | ||
|  |     'Ban' => [ | ||
|  |         'banned', | ||
|  |         sub { ( $_[0] ? $jbanned : $banned ) }, | ||
|  |         'block' | ||
|  |     ], | ||
|  |     'Reject' => [ | ||
|  |         'rejected', | ||
|  |         sub { ( $_[0] ? $jrejected : $rejected ) }, | ||
|  |         'delete' | ||
|  |         ], | ||
|  | ); | ||
|  | 
 | ||
|  | # | ||
|  | # Make a RE from the keys | ||
|  | # | ||
|  | my $mkeys = join('|',keys(%moderation_keys)); | ||
|  | my $mkeys_re = qr{^$mkeys$}; | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Connect to the database | ||
|  | #------------------------------------------------------------------------------- | ||
|  | my $dbhost = $config{database}->{host} // '127.0.0.1'; | ||
|  | my $dbport = $config{database}->{port} // 3306; | ||
|  | my $dbname = $config{database}->{name}; | ||
|  | my $dbuser = $config{database}->{user}; | ||
|  | my $dbpwd  = $config{database}->{password}; | ||
|  | $dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname", | ||
|  |     $dbuser, $dbpwd, { AutoCommit => 1 } ) | ||
|  |     or croak $DBI::errstr; | ||
|  | 
 | ||
|  | #$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport", | ||
|  | #    $dbuser, $dbpwd, { AutoCommit => 1 } ) | ||
|  | #    or croak $DBI::errstr; | ||
|  | 
 | ||
|  | # | ||
|  | # Enable client-side UTF8 | ||
|  | # | ||
|  | $dbh->{mysqlenable_utf8} = 1; | ||
|  | 
 | ||
|  | # | ||
|  | # Set the local timezone to UTC for this connection | ||
|  | # | ||
|  | $dbh->do("set time_zone = '+00:00'") or carp $dbh->errstr; | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Set up logging keeping the default log layout except for the date | ||
|  | #------------------------------------------------------------------------------- | ||
|  | my $log = Log::Handler->new(); | ||
|  | 
 | ||
|  | $log->add( | ||
|  |     file => { | ||
|  |         timeformat => "%F %H:%M:%S (%s)", | ||
|  |         filename   => $logfile, | ||
|  |         maxlevel   => 7, | ||
|  |         minlevel   => 0 | ||
|  |     } | ||
|  | ); | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # If mode JSON is selected with -json option we'll check the defined 'json' | ||
|  | # directory, otherwise we'll look for work in the maildrop. | ||
|  | #------------------------------------------------------------------------------- | ||
|  | if ($jsonmode) { | ||
|  |     # | ||
|  |     # Look for JSON files | ||
|  |     # | ||
|  |     @ignore = ( '*~', '.*.swp', 'banned', 'processed', 'rejected' ); | ||
|  |     my $jsonfileRE = qr{(\.(?i:json)|[^.]+)$}; | ||
|  |     #<<< | ||
|  |     @files = File::Find::Rule | ||
|  |         ->file() | ||
|  |         ->name($jsonfileRE) | ||
|  |         ->not( File::Find::Rule->new->name(@ignore) ) | ||
|  |         ->maxdepth(1) | ||
|  |         ->in($jsondir); | ||
|  |     #>>> | ||
|  | 
 | ||
|  |     if ( $DEBUG > 1 ) { | ||
|  |         _debug(1, | ||
|  |             "Files found in $jsondir", | ||
|  |             Dumper( \@files ), | ||
|  |         ); | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # There may be nothing there | ||
|  |     # | ||
|  |     die "No JSON files found; nothing to do\n" unless (@files); | ||
|  | 
 | ||
|  |     # | ||
|  |     # Convert the JSON files into the %updates hash | ||
|  |     # | ||
|  |     do_JSON_process( \@files, \%updates, $log, $dry_run, $verbose ); | ||
|  | 
 | ||
|  | } | ||
|  | else { | ||
|  |     # | ||
|  |     # Look for mail files | ||
|  |     # | ||
|  |     @ignore = ( '*~', '.*.swp', 'banned', 'processed', 'rejected' ); | ||
|  | 
 | ||
|  |     my $mailfileRE = qr{(\.(?i:eml)|[^.]+)$}; | ||
|  |     #<<< | ||
|  |     @files = File::Find::Rule | ||
|  |         ->file() | ||
|  |         ->name($mailfileRE) | ||
|  |         ->not( File::Find::Rule->new->name(@ignore) ) | ||
|  |         ->maxdepth(1) | ||
|  |         ->in($maildrop); | ||
|  |     #>>> | ||
|  | 
 | ||
|  |     if ( $DEBUG > 1 ) { | ||
|  |         _debug(1, | ||
|  |             "Files found in $maildrop", | ||
|  |             Dumper( \@files ), | ||
|  |         ); | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # There may be nothing there | ||
|  |     # | ||
|  |     unless (@files) { | ||
|  |         warn "No mail found; nothing to do\n"; | ||
|  |         exit; | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # Convert the mail messages into the %updates hash | ||
|  |     # | ||
|  |     do_mail_process( \@files, \%updates, $log, $dry_run, $verbose ); | ||
|  | 
 | ||
|  | } | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Check that comments aren't already in the database | ||
|  | #------------------------------------------------------------------------------- | ||
|  | do_database_check( $dbh, \%updates, $log, $verify, $dry_run ); | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | #  Perform the moderation pass | ||
|  | #------------------------------------------------------------------------------- | ||
|  | do_moderation( $dbh, \%updates, $template, $log, $verify, $dry_run ); | ||
|  | 
 | ||
|  | # | ||
|  | # Dump the data structure if requested | ||
|  | # | ||
|  | _debug($DEBUG > 1, '%updates: ' . Dumper(\%updates)); | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # For the comments which are valid and approved, add them to the database | ||
|  | #------------------------------------------------------------------------------- | ||
|  | print '-' x 80, "\n" if $verbose > 0; | ||
|  | do_update( $dbh, \%updates, $log, $dry_run, $verbose ); | ||
|  | 
 | ||
|  | #------------------------------------------------------------------------------- | ||
|  | # Walk the captured data reporting what happened (would happen) moving the | ||
|  | # files to the appropriate places and telling the server to do things to the | ||
|  | # originals (if appropriate) | ||
|  | #------------------------------------------------------------------------------- | ||
|  | foreach my $file ( sort_by_timestamp( \%updates ) ) { | ||
|  |     # | ||
|  |     # Deal with each file depending on the selected action (from the set | ||
|  |     # 'Approve', 'Ban' and 'Reject') | ||
|  |     # | ||
|  |     if ( $updates{$file}->{action} =~ $mkeys_re ) { | ||
|  |         # | ||
|  |         # Get the elements we need for the function arguments below. | ||
|  |         # NOTE: Apologies! This is obscure. The action key will be 'Approve', | ||
|  |         # 'Ban' or 'Reject', and the lookup will return the name of the | ||
|  |         # destination (for messages), an anonymous subroutine reference which determines | ||
|  |         # the directory, and a keyword to send to the web server to dispose of | ||
|  |         # the comment. The subref must be called with $jsonmode as the | ||
|  |         # argument to get the destination which is different depending on | ||
|  |         # whether we're in mail or JSON mode. This really needs to be made | ||
|  |         # simpler and easier to understand when this code is revisited! | ||
|  |         # | ||
|  |         my ( $name, $destsub, $serverac ) | ||
|  |             = ( @{ $moderation_keys{ $updates{$file}->{action} } } ); | ||
|  |         my $dest = &$destsub($jsonmode); | ||
|  | 
 | ||
|  |         # | ||
|  |         # Print moderation settings | ||
|  |         # | ||
|  |         _debug($DEBUG > 1, | ||
|  |             '$name: ' . $name, | ||
|  |             '$dest: ' . $dest, | ||
|  |             '$serverac: ' . $serverac, | ||
|  |         ); | ||
|  | 
 | ||
|  |         # | ||
|  |         # Report what we're doing with the file and do it if appropriate | ||
|  |         # | ||
|  |         report_action( $updates{$file}, $name, $dest, $log, | ||
|  |             $dry_run, $verbose ); | ||
|  | 
 | ||
|  |         # | ||
|  |         # Tell the server what to do, but only if this is the live instance | ||
|  |         # and we're not in dry-run mode | ||
|  |         # | ||
|  |         if ($live) { | ||
|  |             unless ( | ||
|  |                 call_back( | ||
|  |                     $callback_template, $updates{$file}->{JSON}->{key}, | ||
|  |                     $serverac, $cb_user, $cb_password, $log, $dry_run, $verbose | ||
|  |                 ) | ||
|  |                 ) | ||
|  |             { | ||
|  |                 print "Server update failed\n" if $verbose > 0; | ||
|  |             } | ||
|  |         } | ||
|  |         else { | ||
|  |             unless ($dry_run) { | ||
|  |                 print "Not live; no server action taken\n" if $verbose > 0; | ||
|  |             } | ||
|  |         } | ||
|  |     } | ||
|  |     elsif ( $updates{$file}->{action} eq 'Ignore' ) { | ||
|  |         # | ||
|  |         # The action here is 'Ignore' which means just leave it alone. Since | ||
|  |         # only the moderator can choose this, or the failure to find the | ||
|  |         # episode in the database, we know it's not a broken mail message or | ||
|  |         # bad JSON. | ||
|  |         # | ||
|  |         if ( $verbose > 0 ) { | ||
|  |             print "File '$file' ignored\n"; | ||
|  |             print_errors( $updates{$file}->{errors} ); | ||
|  |         } | ||
|  |     } | ||
|  |     elsif ( $updates{$file}->{skip} > 0 ) { | ||
|  |         # | ||
|  |         # The comment was found to be already in the database so we are | ||
|  |         # skipping it. We want to report this and move the file to the | ||
|  |         # 'processed' directory. | ||
|  |         # | ||
|  |         $shortfile = $updates{$file}->{shortfile}; | ||
|  | 
 | ||
|  |         unless ($dry_run) { | ||
|  |             if ( $verbose > 0 ) { | ||
|  |                 print "Moving '$shortfile' to 'processed'\n"; | ||
|  |                 print "Reason: comment is already in the database\n"; | ||
|  |                 print_errors( $updates{$file}->{errors} ); | ||
|  |             } | ||
|  |             $log->info("Moving $file to 'processed'"); | ||
|  |             unless ( moveFile( $file, ($jsonmode ? $jprocessed : $processed) ) ) { | ||
|  |                 warn "Unable to move $file\n"; | ||
|  |                 $log->error("Failed to move $file"); | ||
|  |             } | ||
|  |         } | ||
|  |         else { | ||
|  |             if ( $verbose > 0 ) { | ||
|  |                 print "Would move '$shortfile' to 'processed' (dry run)\n"; | ||
|  |                 print "Reason: comment is already in the database\n"; | ||
|  |                 print_errors( $updates{$file}->{errors} ); | ||
|  |             } | ||
|  |         } | ||
|  |     } | ||
|  |     else { | ||
|  |         # | ||
|  |         # We aborted this one early in the processing and validation, so it's | ||
|  |         # really messed up. We're going to reject it outright with error | ||
|  |         # messages. We can't tell the server because we don't have the | ||
|  |         # key to do it. | ||
|  |         # | ||
|  |         $shortfile = $updates{$file}->{shortfile}; | ||
|  | 
 | ||
|  |         unless ($dry_run) { | ||
|  |             if ( $verbose > 0 ) { | ||
|  |                 print "Moving $shortfile to 'rejected'\n"; | ||
|  |                 print "Reason: failed validation\n"; | ||
|  |                 print_errors( $updates{$file}->{errors} ); | ||
|  |             } | ||
|  |             $log->info("Moving $file to 'rejected'"); | ||
|  |             unless ( moveFile( $file, ($jsonmode ? $jrejected : $rejected) ) ) { | ||
|  |                 warn "Unable to move $file\n"; | ||
|  |                 $log->error("Failed to move $file"); | ||
|  |             } | ||
|  |         } | ||
|  |         else { | ||
|  |             if ( $verbose > 0 ) { | ||
|  |                 print "Would move $shortfile to 'rejected' (dry run)\n"; | ||
|  |                 print "Reason: failed validation\n"; | ||
|  |                 print_errors( $updates{$file}->{errors} ); | ||
|  |             } | ||
|  |         } | ||
|  |     } | ||
|  | 
 | ||
|  |     print '.' x 80, "\n" if $verbose > 0; | ||
|  | } | ||
|  | 
 | ||
|  | exit; | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: do_JSON_process | ||
|  | #      PURPOSE: Process any pending JSON files found in a directory search | ||
|  | #   PARAMETERS: $files          arrayref containing the JSON files found | ||
|  | #               $updates        hashref to contain the details collected from | ||
|  | #                               the JSON files | ||
|  | #               $log            log handle | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #               $verbose        Integer level of verbosity | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: The files containing JSON comments have been found earlier and | ||
|  | #               passed to this function. They are processed in sorted order. | ||
|  | #               The JSON is decoded into a hash and is validated, and the | ||
|  | #               elements cleaned up. The JSON is stored for later in the | ||
|  | #               $updates structure. If all tests and checks are passed the | ||
|  | #               entry for this file in the $updates structure is marked as | ||
|  | #               valid. If anything fails the entry is marked as invalid for | ||
|  | #               the next stages of processing. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub do_JSON_process { | ||
|  |     my ( $files, $updates, $log, $dry_run, $verbose ) = @_; | ||
|  | 
 | ||
|  |     my ( $shortfile, $dt ); | ||
|  |     my $json = JSON->new; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Examine the files we found | ||
|  |     # | ||
|  |     foreach my $file ( sort(@$files) ) { | ||
|  |         print ">> $file\n" if $verbose > 0; | ||
|  |         $log->notice("Processing $file") unless $dry_run; | ||
|  | 
 | ||
|  |         # | ||
|  |         # Initialise for this file, marking it as invalid by default. | ||
|  |         # | ||
|  |         $updates->{$file}              = {}; | ||
|  |         $updates->{$file}->{valid}     = 0; | ||
|  |         $updates->{$file}->{moderated} = 0; | ||
|  |         $updates->{$file}->{errors}    = []; | ||
|  |         $updates->{$file}->{action}    = ''; | ||
|  |         $updates->{$file}->{file}      = $file;       # For convenience | ||
|  |         $updates->{$file}->{shortfile} = $shortfile = abbreviate_filename($file); | ||
|  | 
 | ||
|  |        # | ||
|  |        # Open the nominated file and load its contents into a scalar. Because | ||
|  |        # 'read_text' does its own internal error handling we need try/catch to | ||
|  |        # intercept it and provide our own. | ||
|  |        # | ||
|  |         my $json_text; | ||
|  |         try { | ||
|  |             $json_text = read_text($file); | ||
|  |         } | ||
|  | 
 | ||
|  |         catch ($e){ | ||
|  |             warn "Failed to read JSON file $file\n"; | ||
|  |             next; | ||
|  |         } | ||
|  | 
 | ||
|  |         # | ||
|  |         # Decode and process the JSON, returning FALSE if it fails so we get | ||
|  |         # a log entry (unless it's dry run mode) | ||
|  |         # | ||
|  |         unless ( | ||
|  |             convert_JSON( | ||
|  |                 $json, $json_text, $updates, $file, | ||
|  |                 $log,  $dry_run,   $verbose | ||
|  |             ) | ||
|  |             ) | ||
|  |         { | ||
|  |             $log->warning("JSON decode failure; aborting '$shortfile'") | ||
|  |                 unless $dry_run; | ||
|  |         } | ||
|  | 
 | ||
|  |     } | ||
|  | 
 | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: do_mail_process | ||
|  | #      PURPOSE: Process any pending email found in a directory search | ||
|  | #   PARAMETERS: $files          arrayref containing the mail files found | ||
|  | #               $updates        hashref to contain the details collected from | ||
|  | #                               the mail files | ||
|  | #               $log            log handle | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #               $verbose        Integer level of verbosity | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: The files containing email have been found earlier and passed | ||
|  | #               to this function. They are processed in sorted order. Each | ||
|  | #               file is parsed as a mail file and the various destination | ||
|  | #               addresses collected. (TODO Check these against permitted | ||
|  | #               addresses). Each message is expected to be in MIME format with | ||
|  | #               an attachment, which is extracted. The target mimetype | ||
|  | #               (application/json) is checked for (TODO This is a global and | ||
|  | #               shouldn't be). The JSON it contains is decoded into a hash and | ||
|  | #               is validated, and the elements cleaned up. The JSON is stored | ||
|  | #               for later in the $updates structure. If all tests and checks | ||
|  | #               are passed the entry for this file in the $updates structure | ||
|  | #               is marked as valid. If anything fails the entry is marked as | ||
|  | #               invalid for the next stages of processing. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub do_mail_process { | ||
|  |     my ( $files, $updates, $log, $dry_run, $verbose ) = @_; | ||
|  | 
 | ||
|  |     my ( $shortfile, $dt ); | ||
|  | 
 | ||
|  |     # | ||
|  |     # Prepare for parsing mail and JSON | ||
|  |     # | ||
|  |     my $parser = MIME::Parser->new; | ||
|  |     $parser->output_under("/tmp"); | ||
|  | 
 | ||
|  |     my $json = JSON->new; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Examine the files we found | ||
|  |     # | ||
|  |     foreach my $file ( sort(@$files) ) { | ||
|  |         print ">> $file\n" if $verbose > 0; | ||
|  |         $log->notice("Processing $file") unless $dry_run; | ||
|  | 
 | ||
|  |         # | ||
|  |         # Initialise for this file, marking it as invalid by default. | ||
|  |         # | ||
|  |         $updates->{$file} = {}; | ||
|  |         $updates->{$file}->{valid} = 0; | ||
|  |         $updates->{$file}->{moderated} = 0; | ||
|  |         $updates->{$file}->{errors} = []; | ||
|  |         $updates->{$file}->{action} = ''; | ||
|  |         $updates->{$file}->{file} = $file;            # For convenience | ||
|  |         $updates->{$file}->{shortfile} = $shortfile = abbreviate_filename($file); | ||
|  | 
 | ||
|  |         # | ||
|  |         # Open the current file and load its contents into a Mail::Internet object | ||
|  |         # TODO: Consider using IO::All | ||
|  |         # | ||
|  |         open( my $mfh, '<', $file ) | ||
|  |             or die "Failed to open input file '$file' : $!\n"; | ||
|  | 
 | ||
|  |         my $mi_obj = Mail::Internet->new($mfh); | ||
|  | 
 | ||
|  |         close($mfh) | ||
|  |             or warn "Failed to close input file '$file' : $!\n"; | ||
|  | 
 | ||
|  |         # | ||
|  |         # This should be to comments@hackerpublicradio.org in one of these headers | ||
|  |         # | ||
|  |         my @addresses = ( | ||
|  |             Mail::Address->parse( $mi_obj->head->get('Resent-to') ), | ||
|  |             Mail::Address->parse( $mi_obj->head->get('Resent-cc') ) | ||
|  |         ); | ||
|  |         if ( !@addresses ) { | ||
|  |             @addresses = ( | ||
|  |                 Mail::Address->parse( $mi_obj->head->get('To') ), | ||
|  |                 Mail::Address->parse( $mi_obj->head->get('Cc') ) | ||
|  |             ); | ||
|  |         } | ||
|  | 
 | ||
|  |         # | ||
|  |         # Report the headers we're interested in if requested | ||
|  |         # | ||
|  |         if ($verbose > 1) { | ||
|  |             foreach my $addr (@addresses) { | ||
|  |                 my $dest = lc( $addr->address ); | ||
|  |                 print "To: $dest\n"; | ||
|  |             } | ||
|  |             print '~ ' x 40,"\n"; | ||
|  |         } | ||
|  | 
 | ||
|  |         # | ||
|  |         # TODO Check the message is actually for us | ||
|  |         # | ||
|  | 
 | ||
|  |         my ( $ct, $cte, @body, $body, $results ); | ||
|  | 
 | ||
|  |         # | ||
|  |         # Parse the message in the assumption it's MIME. Report any errors and | ||
|  |         # warnings. | ||
|  |         # | ||
|  |         my $entity = $parser->parse_data( $mi_obj->as_string() ); | ||
|  |         unless ($entity) { | ||
|  |             $results = $parser->results; | ||
|  |             foreach my $pmsg ( $results->msgs ) { | ||
|  |                 print STDERR "$pmsg\n"; | ||
|  |             } | ||
|  |         } | ||
|  | 
 | ||
|  |         # | ||
|  |         # If we got multiple MIME parts then look for the first 'application/json' | ||
|  |         # element and save it as the body we'll work on. | ||
|  |         # | ||
|  |         if ( scalar( $entity->parts ) > 0 ) { | ||
|  |             # | ||
|  |             # MIME message | ||
|  |             # | ||
|  |             if ( $DEBUG > 1 ) { | ||
|  |                 _debug(1, 'Message is MIME with multiple parts'); | ||
|  |                 foreach my $ent ( $entity->parts ) { | ||
|  |                     _debug(1, "Type: " . $ent->mime_type); | ||
|  |                 } | ||
|  |             } | ||
|  | 
 | ||
|  |             # | ||
|  |             # Get the first MIME part of the type we want and turn it into an | ||
|  |             # array. | ||
|  |             # TODO: Might it be better to make a string here since later we simply | ||
|  |             # convert the array to a string again. | ||
|  |             # | ||
|  |             foreach my $ent ( $entity->parts ) { | ||
|  |                 if ( $ent->mime_type eq $target_mimetype ) { | ||
|  |                     $ct = $ent->mime_type; | ||
|  |                 # | ||
|  |                 # FIXME: Using 'bodyhandle' is supposed to decode the entity | ||
|  |                 # body, but this is untested. Also, we have to use 'as_string' | ||
|  |                 # then split on "\n" and stick the newlines back on the array | ||
|  |                 # elements, which seems a bit weird. Have to monitor this to | ||
|  |                 # see how it behaves. | ||
|  |                 # | ||
|  |                 #@body = @{ $ent->body() }; | ||
|  |                     @body = map {"$_\n"} | ||
|  |                         split( "\n", $ent->bodyhandle->as_string ); | ||
|  |                     _debug($DEBUG > 1, | ||
|  |                         'Length of body = ' . scalar(@body) . ' lines' | ||
|  |                     ); | ||
|  | 
 | ||
|  |                     # | ||
|  |                     # If we got a non-empty body then exit the loop | ||
|  |                     # | ||
|  |                     last if @body; | ||
|  |                 } | ||
|  |             } | ||
|  | 
 | ||
|  |             # | ||
|  |             # We found no suitable part so there's nothing to process here. We'll | ||
|  |             # let the later phases detect this though | ||
|  |             # | ||
|  |             unless (@body) { | ||
|  |                 saveError( "MIME message has no valid '$target_mimetype' element", | ||
|  |                     $updates->{$file}->{errors} , $log, $dry_run, $verbose ); | ||
|  |                 next; | ||
|  |             } | ||
|  |         } | ||
|  |         else { | ||
|  |             # | ||
|  |             # The message has no MIME parts | ||
|  |             # | ||
|  |             saveError( "Message has no attachment", | ||
|  |                 $updates->{$file}->{errors}, $log, $dry_run, $verbose ); | ||
|  |             next; | ||
|  |         } | ||
|  | 
 | ||
|  |         # | ||
|  |         # Display the body unless we're not very verbose | ||
|  |         # | ||
|  |         $body = join( "", @body ); | ||
|  |         print "$body\n" if $verbose > 2; | ||
|  | 
 | ||
|  |         # | ||
|  |         # Decode and process the JSON, returning FALSE if it fails so we get | ||
|  |         # a log entry (unless it's dry run mode) | ||
|  |         # | ||
|  |         unless ( | ||
|  |             convert_JSON( | ||
|  |                 $json, $body, $updates, $file, | ||
|  |                 $log,  $dry_run,   $verbose | ||
|  |             ) | ||
|  |             ) | ||
|  |         { | ||
|  |             $log->warning("JSON decode failure; aborting '$shortfile'") | ||
|  |                 unless $dry_run; | ||
|  |         } | ||
|  | 
 | ||
|  |     } | ||
|  |     continue { | ||
|  |         print '-' x 80, "\n" if $verbose > 0; | ||
|  |     } | ||
|  | 
 | ||
|  |     return; | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: do_database_check | ||
|  | #      PURPOSE: Look to see if the collected comments are already in the | ||
|  | #               database | ||
|  | #   PARAMETERS: $dbh            database handle | ||
|  | #               $updates        structure (hashref) holding collected | ||
|  | #                               information about the files and comments in | ||
|  | #                               them | ||
|  | #               $log            log handle | ||
|  | #               $verify         Boolean flagging the need to verify | ||
|  | #               TODO: Is this right? | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: Looks for each comment in %$updates in the database. The | ||
|  | #               search criteria are the comment timestamp (in database format) | ||
|  | #               and the comment author name. The timestamp is expected to have | ||
|  | #               been converted from full ISO format (2024-01-10T11:16:11Z) to | ||
|  | #               the format MySQL likes (2024-01-10 11:16:11). the original | ||
|  | #               date is in the element 'orig_timestamp'. If there is a match | ||
|  | #               in the database then the comment is to be skipped, and we mark | ||
|  | #               the element appropriately and display and log a message. If no | ||
|  | #               match we can proceed without skipping; | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub do_database_check { | ||
|  |     my ( $dbh, $updates, $log, $verify, $dry_run ) = @_; | ||
|  | 
 | ||
|  |     my ( $sth1, $h1, $shortfile, $message ); | ||
|  | 
 | ||
|  |     # | ||
|  |     # Look for a comment with details matching an incoming one. If found | ||
|  |     # someone else has approved it so we need to skip it. | ||
|  |     # | ||
|  |     $sth1 = $dbh->prepare( | ||
|  |         q{SELECT id FROM comments | ||
|  |         WHERE comment_timestamp = ? | ||
|  |         AND comment_author_name = ?} | ||
|  |     ) or die $DBI::errstr; | ||
|  |     if ( $dbh->err ) { | ||
|  |         warn $dbh->errstr; | ||
|  |     } | ||
|  | 
 | ||
|  |     foreach my $file ( sort_by_timestamp($updates) ) { | ||
|  |         # | ||
|  |         # Make a shortcut to the JSON part, and collect the short form of the | ||
|  |         # filename. | ||
|  |         # | ||
|  |         my $ptr = $updates->{$file}->{JSON}; | ||
|  |         #my $errors = $updates->{$file}->{errors}; | ||
|  |         $shortfile = $updates{$file}->{shortfile}; | ||
|  | 
 | ||
|  |         # | ||
|  |         # Look for the comment in the database | ||
|  |         # | ||
|  |         $sth1->execute( $ptr->{comment_timestamp}, | ||
|  |             $ptr->{comment_author_name} ); | ||
|  |         if ( $dbh->err ) { | ||
|  |             warn $dbh->errstr; | ||
|  |         } | ||
|  | 
 | ||
|  |         # | ||
|  |         # Did we find it? | ||
|  |         # | ||
|  |         if ( $h1 = $sth1->fetchrow_hashref() ) { | ||
|  |             # | ||
|  |             # Found in the database. Mark this comment to be skipped and save | ||
|  |             # an error report | ||
|  |             # | ||
|  |             #$updates->{$file}->{valid} = 0; | ||
|  |             $updates->{$file}->{skip} = 1; | ||
|  | 
 | ||
|  |             $message = "Comment in file '$shortfile' is already in the database"; | ||
|  |             print "$message\n" if $verbose > 0; | ||
|  |             $log->warn($message) unless $dry_run; | ||
|  |         } | ||
|  |         else { | ||
|  |             # | ||
|  |             # Not found | ||
|  |             # | ||
|  |             $updates->{$file}->{skip} = 0; | ||
|  |         } | ||
|  | 
 | ||
|  |     } | ||
|  | 
 | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: do_moderation | ||
|  | #      PURPOSE: Moderate all the valid comments in the 'updates' structure | ||
|  | #   PARAMETERS: $dbh            database handle | ||
|  | #               $updates        structure (hashref) holding collected | ||
|  | #                               information about the files and comments in | ||
|  | #                               them | ||
|  | #               $tt_template    path to the template we're using | ||
|  | #               $log            log handle | ||
|  | #               $verify         Boolean flagging the need to verify | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: Performs the moderation pass on the accumulated comments in | ||
|  | #               the $updates structure. This is a hash (referenced by | ||
|  | #               $updates) containing the filename as the key. Each of these | ||
|  | #               lead to a hashref containing the JSON data (in a hashref), an | ||
|  | #               arrayref of error messages and various flags denoting whether | ||
|  | #               the comment is valid, has been moderated and what action the | ||
|  | #               moderator took. | ||
|  | #               The flags and error messages are manipulated here depending on | ||
|  | #               what choice is made during the moderation process. | ||
|  | #               The contents of the JSON hash (or a subset thereof) and the | ||
|  | #               data about the show returned from a database query are | ||
|  | #               displayed for the moderator using a template, and a menu of | ||
|  | #               'Approve', 'Ban', 'Reject', Ignore' is presented. | ||
|  | #               On exit the structure referenced by $updates has been modified | ||
|  | #               to signal what should be done with each of the comments. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: TODO: References globals and should not. | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub do_moderation { | ||
|  |     my ( $dbh, $updates, $tt_template, $log, $verify, $dry_run ) = @_; | ||
|  | 
 | ||
|  |     my ( $sth1, $h1, $cdt, $age ); | ||
|  |     my $agelimit = 60 * 60 * 24; | ||
|  | 
 | ||
|  |     # | ||
|  |     # We'll get details of the show each comment is associated with using this | ||
|  |     # query. It will be passed to the TT² template. | ||
|  |     # | ||
|  |     $sth1 = $dbh->prepare( | ||
|  |         q{SELECT e.id, e.date, e.title, h.host FROM eps e | ||
|  |         JOIN hosts h ON e.hostid = h.hostid | ||
|  |         WHERE e.id = ?} | ||
|  |     ) or die $DBI::errstr; | ||
|  |     if ( $dbh->err ) { | ||
|  |         warn $dbh->errstr; | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # We'll display each comment with a TT² template | ||
|  |     # | ||
|  |     my $tt = Template->new( | ||
|  |         {   ABSOLUTE     => 1, | ||
|  |             ENCODING     => 'utf8', | ||
|  |             INCLUDE_PATH => $basedir, | ||
|  |         } | ||
|  |     ); | ||
|  |     my $vars = {}; | ||
|  |     my $choice; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Process the valid comments, and not marked as 'skip' | ||
|  |     # | ||
|  |     foreach my $file ( sort_by_timestamp($updates) ) { | ||
|  |         if ( $updates->{$file}->{valid} > 0 && $updates->{$file}->{skip} == 0 ) { | ||
|  |             # | ||
|  |             # Make shortcuts | ||
|  |             # | ||
|  |             my $ptr = $updates->{$file}->{JSON}; | ||
|  |             my $errors = $updates->{$file}->{errors}; | ||
|  | 
 | ||
|  |             # | ||
|  |             # Check the age of the comment if we're verifying, and ignore if | ||
|  |             # less than the age limit. The original timestamp should have been | ||
|  |             # reserved even though we had to reformat for MySQL/MariaDB. | ||
|  |             # | ||
|  |             if ($verify) { | ||
|  |                 $cdt = DateTime::Format::ISO8601->parse_datetime( | ||
|  |                     $ptr->{orig_timestamp} | ||
|  |                 ); | ||
|  |                 $age = DateTime->now->epoch - $cdt->epoch; | ||
|  |                 if ($age < $agelimit) { | ||
|  |                     print "Choice 'Ignore' auto-selected due to comment age\n"; | ||
|  |                     $updates->{$file}->{action} = 'Ignore'; | ||
|  |                     $log->info("Ignored comment in $file") | ||
|  |                         unless $dry_run; | ||
|  |                     $updates->{$file}->{moderated} = 0; | ||
|  |                     next; | ||
|  |                 } | ||
|  |             } | ||
|  | 
 | ||
|  |             # | ||
|  |             # Get the show details from the database | ||
|  |             # | ||
|  |             $sth1->execute( $ptr->{eps_id} ); | ||
|  |             if ( $dbh->err ) { | ||
|  |                 warn $dbh->errstr; | ||
|  |             } | ||
|  | 
 | ||
|  |             # | ||
|  |             # Do we know this episode? | ||
|  |             # NOTE: If the database we're using is the clone this might fail | ||
|  |             # because it's out of step with the live one | ||
|  |             # | ||
|  |             if ( $h1 = $sth1->fetchrow_hashref() ) { | ||
|  |                 # | ||
|  |                 # Populate the variables for the template | ||
|  |                 # | ||
|  |                 $vars->{file}    = $file; | ||
|  |                 $vars->{db}      = $h1; | ||
|  |                 $vars->{comment} = $ptr; | ||
|  | 
 | ||
|  |                 # | ||
|  |                 # Call the template and print the result | ||
|  |                 # | ||
|  |                 my $document; | ||
|  |                 $tt->process( $tt_template, $vars, \$document, | ||
|  |                     { binmode => ':utf8' } ) | ||
|  |                     || die $tt->error(), "\n"; | ||
|  |                 print $document; | ||
|  | 
 | ||
|  |                 # | ||
|  |                 # Prompt for moderation. Note: these prompts are used to flag | ||
|  |                 # the choice in the %updates hash, so don't change them unless | ||
|  |                 # you also change them elsewhere. | ||
|  |                 # | ||
|  |                 $choice = prompt( | ||
|  |                     'Choose...', | ||
|  |                     -menu => [ 'Approve', 'Ban', 'Reject', 'Ignore' ], | ||
|  |                     '>' | ||
|  |                 ); | ||
|  |                 if ($choice) { | ||
|  |                     print "Choice '$choice' selected\n"; | ||
|  |                     if ( $choice eq 'Approve' ) { | ||
|  |                         $updates->{$file}->{action} = $choice; | ||
|  |                         $log->info("Approved comment in $file") | ||
|  |                             unless $dry_run; | ||
|  |                         $updates->{$file}->{moderated} = 1; | ||
|  |                     } | ||
|  |                     elsif ( $choice eq 'Ban' ) { | ||
|  |                         $updates->{$file}->{action} = $choice; | ||
|  |                         saveError( | ||
|  |                             "Comment in '$file' banned by the moderator", | ||
|  |                             $errors, | ||
|  |                             $log, $dry_run, 0 | ||
|  |                         ); | ||
|  |                         $updates->{$file}->{valid}     = 0; | ||
|  |                         $updates->{$file}->{moderated} = 1; | ||
|  |                     } | ||
|  |                     elsif ( $choice eq 'Reject' ) { | ||
|  |                         $updates->{$file}->{action} = $choice; | ||
|  |                         saveError( | ||
|  |                             "Comment in '$file' rejected by the moderator", | ||
|  |                             $errors, | ||
|  |                             $log, $dry_run, 0 | ||
|  |                         ); | ||
|  |                         $updates->{$file}->{valid}     = 0; | ||
|  |                         $updates->{$file}->{moderated} = 1; | ||
|  |                     } | ||
|  |                     elsif ( $choice eq 'Ignore' ) { | ||
|  |                         $updates->{$file}->{action} = $choice; | ||
|  |                         $log->info("Ignored comment in $file") | ||
|  |                             unless $dry_run; | ||
|  |                         $updates->{$file}->{moderated} = 0; | ||
|  |                     } | ||
|  |                 } | ||
|  |                 else { | ||
|  |                     print "\n"; | ||
|  |                     undef $choice; # Seem to need this after aborting 'prompt' | ||
|  |                     print "Ignored this comment\n"; | ||
|  |                     $log->info("Ignored comment in $file (default action)") | ||
|  |                         unless $dry_run; | ||
|  |                     $updates->{$file}->{moderated} = 0; | ||
|  |                 } | ||
|  |                 #print "\n"; | ||
|  |             } | ||
|  |             else { | ||
|  |                 # | ||
|  |                 # We couldn't find the episode in the database | ||
|  |                 # | ||
|  |                 printf "Episode %s is not known\n", $ptr->{eps_id}; | ||
|  |                 $updates->{$file}->{action} = 'Ignore'; | ||
|  |                 saveError( | ||
|  |                     "Episode referenced in '$file' is not known", | ||
|  |                     $errors, | ||
|  |                     $log, $dry_run, 0 | ||
|  |                 ); | ||
|  |                 $updates->{$file}->{moderated} = 0; | ||
|  |             } | ||
|  |         } | ||
|  |     } | ||
|  | 
 | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: do_update | ||
|  | #      PURPOSE: Add valid, approved comments to the database (or pretend to do | ||
|  | #               so if in dry-run mode) | ||
|  | #   PARAMETERS: $dbh            database handle | ||
|  | #               $updates        structure (hashref) holding collected | ||
|  | #                               information about the files and comments in | ||
|  | #                               them | ||
|  | #               $log            log handle | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #               $verbose        Integer level of verbosity | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: Performs a database update for all valid approved comments in | ||
|  | #               the structure referenced by $updates unless in dry-run mode | ||
|  | #               where what would be done will be reported. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: TODO: References globals and should not. | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub do_update { | ||
|  |     my ( $dbh, $updates, $log, $dry_run, $verbose ) = @_; | ||
|  | 
 | ||
|  |     my $sql = SQL::Abstract->new; | ||
|  |     my ( $stmt, @bind, $show, $count ); | ||
|  | 
 | ||
|  |     $count = 0; | ||
|  | 
 | ||
|  |     foreach my $file ( sort_by_timestamp($updates) ) { | ||
|  |         if (   $updates->{$file}->{valid} > 0 | ||
|  |             && $updates->{$file}->{action} eq 'Approve' ) | ||
|  |         { | ||
|  |             $count++; | ||
|  | 
 | ||
|  |             $show = $updates->{$file}->{JSON}->{eps_id}; | ||
|  | 
 | ||
|  |             my $shortfile = $updates{$file}->{shortfile}; | ||
|  | 
 | ||
|  |             # | ||
|  |             # Build the statement | ||
|  |             # NOTE: The JSON keys we wish to use match the column names in the | ||
|  |             # 'comments' table. The second argument to 'insert' needs to be | ||
|  |             # a hashref, so we use @json_keys to list the keys we need, with | ||
|  |             # the whole expression returning an anonymous hash (hashref) | ||
|  |             # | ||
|  |             ( $stmt, @bind ) | ||
|  |                 = $sql->insert( 'comments', | ||
|  |                 { %{ $updates->{$file}->{JSON} }{@json_keys} } ); | ||
|  | 
 | ||
|  |             # | ||
|  |             # Display the statement we constructed if requested | ||
|  |             # | ||
|  |             _debug($DEBUG > 1, | ||
|  |                 "$stmt\n'", | ||
|  |                 join( "','", @bind ) | ||
|  |             ); | ||
|  | 
 | ||
|  |             # | ||
|  |             # Take action or report potential changes | ||
|  |             # | ||
|  |             unless ($dry_run) { | ||
|  |                 # | ||
|  |                 # Apply the SQL | ||
|  |                 # | ||
|  |                 $dbh->do( $stmt, undef, @bind ); | ||
|  |                 if ( $dbh->err ) { | ||
|  |                     # | ||
|  |                     # Something broke during the database update. Warn about | ||
|  |                     # it, save the error and make the action 'Ignore' so the | ||
|  |                     # file is kept for further investigation (or to be | ||
|  |                     # edited). Marking the file as invalid may be overkill. | ||
|  |                     # | ||
|  |                     warn $dbh->errstr; | ||
|  |                     saveError( | ||
|  |                         $dbh->errstr, | ||
|  |                         $updates{$file}->{errors}, | ||
|  |                         $log, $dry_run, $verbose | ||
|  |                     ); | ||
|  | #                    $updates{$file}->{valid} = 0; | ||
|  |                     $updates->{$file}->{action} = 'Ignore'; | ||
|  |                 } | ||
|  |                 else { | ||
|  |                     $log->info("$file: inserted comment for show $show"); | ||
|  | 
 | ||
|  |                     if ( $verbose > 0 ) { | ||
|  |                         print "Updated show $show from file $shortfile\n"; | ||
|  |                     } | ||
|  |                 } | ||
|  |             } | ||
|  |             else { | ||
|  |                 print | ||
|  |                     "Would have added comment for show $show from file $shortfile\n" | ||
|  |                     if $verbose > 0; | ||
|  |             } | ||
|  | 
 | ||
|  |         } | ||
|  |     } | ||
|  | 
 | ||
|  |     if ($verbose > 0 && $count > 0) { | ||
|  |         print "\n", '-' x 80, "\n"; | ||
|  |     } | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: convert_JSON | ||
|  | #      PURPOSE: Converts the JSON from a file or an email into an internal | ||
|  | #               data structure | ||
|  | #   PARAMETERS: $json           JSON object | ||
|  | #               $jtext          JSON in text format | ||
|  | #               $updates        hashref to contain the details collected from | ||
|  | #                               the JSON and elsewhere | ||
|  | #               $file           the file we're processing (hash key) | ||
|  | #               $log            log handle | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #               $verbose        Integer level of verbosity | ||
|  | #      RETURNS:  | ||
|  | #  DESCRIPTION:  | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub convert_JSON { | ||
|  |     my ( $json, $jtext, $updates, $file, $log, $dry_run, $verbose ) = @_; | ||
|  | 
 | ||
|  |     my ($comment, $dt, $shortfile); | ||
|  | 
 | ||
|  |     $shortfile = $updates{$file}->{shortfile}; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Decode the JSON into a hashref | ||
|  |     # | ||
|  |     try { | ||
|  |         $comment = $json->decode($jtext); | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # If the JSON decode failed we have a problem | ||
|  |     # | ||
|  |     catch ($e){ | ||
|  |         saveError( | ||
|  |             "JSON in file '$shortfile' failed to decode", | ||
|  |             $updates->{$file}->{errors}, | ||
|  |             $log, $dry_run, $verbose | ||
|  |         ); | ||
|  |         return 0; | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # Validate the comment fields: do we have all we expected? | ||
|  |     # | ||
|  |     unless (validate_comment($comment)) { | ||
|  |         saveError( | ||
|  |             "JSON in file '$shortfile' failed to validate", | ||
|  |             $updates->{$file}->{errors}, | ||
|  |             $log, $dry_run, $verbose | ||
|  |         ); | ||
|  |         return 0; | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # Perform some cleaning | ||
|  |     # | ||
|  |     foreach my $key (@cleanable_keys) { | ||
|  |         $comment->{$key} = clean_comment_field( $comment->{$key} ); | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # ISO 8601 timestamps cause MySQL problems (or have started to) so we | ||
|  |     # parse them and convert to an acceptable format. We keep the original | ||
|  |     # format in $verify is set and we have to check its age. | ||
|  |     # | ||
|  |     try { | ||
|  |         $dt = DateTime::Format::ISO8601->parse_datetime( | ||
|  |             $comment->{comment_timestamp} ); | ||
|  |     } | ||
|  |     catch ($e){ | ||
|  |         die "Failed to parse comment timestamp\n"; | ||
|  |     } | ||
|  |     $comment->{orig_timestamp} = $comment->{comment_timestamp}; | ||
|  |     $comment->{comment_timestamp} = $dt->ymd . ' ' . $dt->hms; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Stash the JSON for later | ||
|  |     # | ||
|  |     $updates->{$file}->{JSON} = $comment; | ||
|  | 
 | ||
|  |     # | ||
|  |     # This file is valid we think | ||
|  |     # | ||
|  |     $updates->{$file}->{valid} = 1; | ||
|  |     $log->info("$file seems valid") unless $dry_run; | ||
|  | 
 | ||
|  |     return 1; | ||
|  | 
 | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: report_action | ||
|  | #      PURPOSE: Does all of the necessary reporting as the email or JSON files | ||
|  | #               are being acted upon | ||
|  | #   PARAMETERS: $struct         hashref holding collected comment data for | ||
|  | #                               a given mail file | ||
|  | #               $destname       name of destination | ||
|  | #               $dest           destination directory | ||
|  | #               $log            log handle | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #               $verbose        Integer level of verbosity | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: In dry run mode reports what would have been done along with | ||
|  | #               any errors, but only in verbose mode. Otherwise reports the | ||
|  | #               action (in verbose mode) and logs it too. It also moves the | ||
|  | #               mail file to the requested directory. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub report_action { | ||
|  |     my ( $struct, $destname, $dest, $log, $dry_run, $verbose ) = @_; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Get values with a hashref slice | ||
|  |     # | ||
|  |     my ( $file, $shortfile ) = @$struct{ 'file', 'shortfile' }; | ||
|  | 
 | ||
|  |     unless ($dry_run) { | ||
|  |         if ($verbose > 0) { | ||
|  |             print "Moving '$shortfile' to '$destname'\n"; | ||
|  |             print_errors($struct->{$file}->{errors}); | ||
|  |         } | ||
|  |         $log->info("Moving '$file' to '$destname'"); | ||
|  |         unless ( moveFile( $file, $dest ) ) { | ||
|  |             warn "Unable to move $shortfile\n"; | ||
|  |             $log->error("Failed to move $shortfile"); | ||
|  |         } | ||
|  |     } | ||
|  |     else { | ||
|  |         if ($verbose > 0) { | ||
|  |             print "Would move '$shortfile' to '$destname' (dry run)\n"; | ||
|  |             print_errors($struct->{$file}->{errors}); | ||
|  |         } | ||
|  |     } | ||
|  | 
 | ||
|  |     return; | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: call_back | ||
|  | #      PURPOSE: Invokes a PHP interface on the HPR server through which | ||
|  | #               a comment can be marked as fully processed | ||
|  | #   PARAMETERS: $format         sprintf template for building the URL | ||
|  | #               $key            the key from the JSON | ||
|  | #               $action         the action to be taken | ||
|  | #               $user           the username from the configuration | ||
|  | #               $pass           the password from the configuration | ||
|  | #               $log            log handle | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #               $verbose        Integer level of verbosity | ||
|  | #      RETURNS: True (1) if the call performed as it should, otherwise false | ||
|  | #               (0) | ||
|  | #  DESCRIPTION: Checks that the correct parameters have been supplied, then | ||
|  | #               builds the URL we're going to use from the format string, key | ||
|  | #               and action. If in dry-run mode this URL is reported and | ||
|  | #               nothing more. Otherwise the URL is queried with an HTTP 'GET'. | ||
|  | #               If the response shows success then the action is considered to | ||
|  | #               have been taken. The returned message is displayed (in verbose | ||
|  | #               mode) and success is logged. If the 'GET' failed then | ||
|  | #               a warning message is generated along with a warning log entry. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub call_back { | ||
|  |     my ( $format, $key, $action, $user, $pass, $log, $dry_run, $verbose ) = @_; | ||
|  | 
 | ||
|  |     my ( $ua, $url, $response, $code, $message, $content ); | ||
|  | 
 | ||
|  |     # | ||
|  |     # Check calling parameters | ||
|  |     # | ||
|  |     unless ($key) { | ||
|  |         warn "Invalid call to 'call_back' subroutine; missing key\n"; | ||
|  |         return 0; | ||
|  |     } | ||
|  | 
 | ||
|  |     unless ( $action =~ /^(approve|block|delete)$/ ) { | ||
|  |         warn "Invalid call to 'call_back' subroutine; invalid action\n"; | ||
|  |         return 0; | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # Build the URL | ||
|  |     # | ||
|  |     $url = sprintf( $format, $key, $action ); | ||
|  | 
 | ||
|  |     # | ||
|  |     # Perform the call to clean up the JSON files, etc, or just say what would | ||
|  |     # have been done | ||
|  |     # | ||
|  |     unless ($dry_run) { | ||
|  |         print "Making the server call:\nGET $url\n" if $verbose > 0; | ||
|  | 
 | ||
|  |         $ua = LWP::UserAgent->new; | ||
|  |         $ua->timeout(180);                  # Module default is 180 sec = 3 mins | ||
|  |         $ua->credentials("hub.hackerpublicradio.org:443", "cms", $user, $pass); | ||
|  | 
 | ||
|  |         $response = $ua->get($url); | ||
|  |         if ( $response->is_success ) { | ||
|  |             # | ||
|  |             # Server call succeeded | ||
|  |             # | ||
|  |             $code = $response->code; | ||
|  |             $message = $response->message; | ||
|  |             $content = $response->decoded_content; | ||
|  |             if ($verbose > 0) { | ||
|  |                 print "Success: $code/$message\n"; | ||
|  |                 print "Content: $content\n" if $content; | ||
|  |             } | ||
|  |             $log->info("Successful server message $key/$action: " . | ||
|  |                 $response->status_line); | ||
|  |             return 1; | ||
|  |         } | ||
|  |         else { | ||
|  |             # | ||
|  |             # Server call failed | ||
|  |             # | ||
|  |             warn $response->status_line . "\n"; | ||
|  |             $log->error("Failed server message $key/$action: " . | ||
|  |                 $response->status_line); | ||
|  |             # Try to log as much as possible | ||
|  |             $log->error( $response->decoded_content ) | ||
|  |                 if $response->decoded_content; | ||
|  |             return 0; | ||
|  |         } | ||
|  |     } | ||
|  |     else { | ||
|  |         # | ||
|  |         # Always a success in dry-run mode | ||
|  |         # | ||
|  |         print "Would have made the server call:\nGET $url\n" if $verbose > 0; | ||
|  |         return 1; | ||
|  |     } | ||
|  | 
 | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: print_errors | ||
|  | #      PURPOSE: Prints the contents of the error array if there's anything in | ||
|  | #               it (i.e. not empty) | ||
|  | #   PARAMETERS: $errors         arrayref possibly containing errors | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: Simplifies printing in the case when there's nothing to print | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub print_errors { | ||
|  |     my ($errors) = @_; | ||
|  | 
 | ||
|  |     return unless defined($errors); | ||
|  | 
 | ||
|  |     print "Errors: ", join( "\n", @{$errors} ), "\n" | ||
|  |         if @{$errors}; | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: saveError | ||
|  | #      PURPOSE: Saves an error message in an error structure and logs it if in | ||
|  | #               live mode | ||
|  | #   PARAMETERS: $message        error message text | ||
|  | #               $struct         structure (arrayref) to hold the text | ||
|  | #               $log            log handle | ||
|  | #               $dry_run        Boolean flagging dry-run mode | ||
|  | #               $verbose        Integer level of verbosity | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: Simplifies the saving of an error message and logging it | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub saveError { | ||
|  |     my ( $message, $struct, $log, $dry_run, $verbose ) = @_; | ||
|  | 
 | ||
|  |     $log->warn($message) unless $dry_run; | ||
|  |     push( @{$struct}, $message ); | ||
|  |     print "$message\n" if $verbose > 0; | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: validate_comment | ||
|  | #      PURPOSE: Checks that all of the required fields are present in the | ||
|  | #               JSON comment | ||
|  | #   PARAMETERS: $comment        - a pointer to the JSON hash | ||
|  | #      RETURNS: True (1) if valid, otherwise false (0) | ||
|  | #  DESCRIPTION: | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub validate_comment { | ||
|  |     my ($comment) = @_; | ||
|  | 
 | ||
|  |     foreach my $key (@json_keys) { | ||
|  |         return 0 unless defined($comment->{$key}); | ||
|  |     } | ||
|  | 
 | ||
|  |     return 1; | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: count_tags | ||
|  | #      PURPOSE: Counts the number of start tags in what might be HTML | ||
|  | #   PARAMETERS: $string         String to examine | ||
|  | #      RETURNS: An integer count of the number of start tags found | ||
|  | #  DESCRIPTION: Uses HTML::Parser to parse the input string. A handler is | ||
|  | #               defined which accumulates start and end tags in an array. The | ||
|  | #               number of start tags found is returned to the caller. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub count_tags { | ||
|  |     my ($string) = @_; | ||
|  | 
 | ||
|  |     my @accum; | ||
|  |     chomp($string); | ||
|  |     return 0 unless $string; | ||
|  | 
 | ||
|  |     my $p = HTML::Parser->new( | ||
|  |         api_version => 3, | ||
|  |         handlers    => { | ||
|  |             start => [ \@accum, "event,text" ], | ||
|  |             end   => [ \@accum, "event,text" ] | ||
|  |         } | ||
|  |     ); | ||
|  |     $p->parse($string); | ||
|  |     $p->eof; | ||
|  | 
 | ||
|  |     return scalar(@accum); | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: clean_comment_field | ||
|  | #      PURPOSE: Cleans a string field from the comment | ||
|  | #   PARAMETERS: $field - the field to be cleaned | ||
|  | #      RETURNS: The cleaned string | ||
|  | #  DESCRIPTION: We want to remove all HTML. We also want to add HTML entities | ||
|  | #               where these may be needed. If there are already entities in | ||
|  | #               the data we will decode them first then re-encode so we don't | ||
|  | #               get double encoding. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub clean_comment_field { | ||
|  |     my ($field) = @_; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Remove all HTML | ||
|  |     # | ||
|  |     my $hr = HTML::Restrict->new(); | ||
|  |     $field = $hr->process($field); | ||
|  | 
 | ||
|  |     # | ||
|  |     # Entities. Make sure all existing entities are decoded first, then encode | ||
|  |     # the result. Note that the unsafe characters are the reverse of what's | ||
|  |     # listed. The second argument to encode_entities uses regular expression | ||
|  |     # character class syntax (as if in [] in a RE). The set is all non-plain | ||
|  |     # ASCII exclusing newline, carriage return and '<>&'. | ||
|  |     # | ||
|  |     decode_entities($field); | ||
|  |     $field | ||
|  |         = encode_entities( $field, '^\n\r\x20-\x25\x27-\x3b\x3d\x3f-\x7e' ); | ||
|  | 
 | ||
|  |     return $field; | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: moveFile | ||
|  | #      PURPOSE: Moves a file (renames it) taking account of whether the name | ||
|  | #               exists at the destination | ||
|  | #   PARAMETERS: $file           file to move | ||
|  | #               $dest           destination of the file | ||
|  | #      RETURNS: True (1) if the move succeeded, otherwise false (0) | ||
|  | #  DESCRIPTION: A wrapper around the File::Copy 'move' function. If the target | ||
|  | #               file exists then a version number is appended to the name so | ||
|  | #               that the original is not clobbered. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub moveFile { | ||
|  |     my ( $file, $dest ) = @_; | ||
|  | 
 | ||
|  |     my ( $target, $basename, $prefix, $vsn ); | ||
|  | 
 | ||
|  |     return 0 unless -e $file; | ||
|  | 
 | ||
|  |     # | ||
|  |     # Handle a destination directory | ||
|  |     # | ||
|  |     if ( -d $dest ) { | ||
|  |         ( $basename = $file ) =~ s|.*/||mx; | ||
|  |         $target = "$dest/$basename"; | ||
|  |     } | ||
|  |     else { | ||
|  |         $target = $dest; | ||
|  |     } | ||
|  | 
 | ||
|  |     # | ||
|  |     # If the target exists we'll have to do some work, otherwise just do the | ||
|  |     # move | ||
|  |     # | ||
|  |     if ( -e $target ) { | ||
|  |         if ( ( $prefix, $vsn ) = ( $target =~ /(\.)(\d+)$/ ) ) { | ||
|  |             while ( -e "$prefix$vsn" ) { | ||
|  |                 $vsn++; | ||
|  |                 $target = "$prefix$vsn"; | ||
|  |             } | ||
|  |             return move( $file, $target ); | ||
|  |         } | ||
|  |         else { | ||
|  |             $vsn    = 1; | ||
|  |             $prefix = $target; | ||
|  |             $target = "$prefix.$vsn"; | ||
|  |             while ( -e "$prefix.$vsn" ) { | ||
|  |                 $vsn++; | ||
|  |                 $target = "$prefix.$vsn"; | ||
|  |             } | ||
|  |             return move( $file, $target ); | ||
|  |         } | ||
|  |     } | ||
|  |     else { | ||
|  |         return move( $file, $dest ); | ||
|  |     } | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: abbreviate_filename | ||
|  | #      PURPOSE: Abbreviates a long pathname by shrinking the directory names. | ||
|  | #   PARAMETERS: $filename       The name of the file | ||
|  | #      RETURNS: A string containing the abbreviated name | ||
|  | #  DESCRIPTION: Splits the filename, assembles the directories as just their | ||
|  | #               first character, and then appends the filename. A bit | ||
|  | #               primitive but it works. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub abbreviate_filename { | ||
|  |     my ($filename) = @_; | ||
|  | 
 | ||
|  |     my @elements = split('/',$filename); | ||
|  |     my @shortened = map {substr($_,0,1)} @elements[0..$#elements-1]; | ||
|  | 
 | ||
|  |     return join('/',@shortened,$elements[-1]); | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: sort_by_timestamp | ||
|  | #      PURPOSE: Sorts the main hash by timestamp rather than the normal sort | ||
|  | #               by key | ||
|  | #   PARAMETERS: $struct         hashref pointing to the main structure | ||
|  | #      RETURNS: A list of hash keys in order of timestamp | ||
|  | #  DESCRIPTION: Generates a list of keys by sorting based on the comment | ||
|  | #               timestamp in the JSON hash. Performs the sort by doing | ||
|  | #               a string comparison since the dates are in ISO8601 | ||
|  | #               'YYYY-MM-DDTHH:MM:SSZ' format (e.g. 2017-10-06T06:22:55Z). | ||
|  | #               This seems to be safe. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub sort_by_timestamp { | ||
|  |     my ($struct) = @_; | ||
|  | 
 | ||
|  |     my @result = sort { | ||
|  |         $struct->{$a}->{JSON}->{comment_timestamp} | ||
|  |             cmp $struct->{$b}->{JSON}->{comment_timestamp} | ||
|  |     } keys(%$struct); | ||
|  | 
 | ||
|  |     return @result; | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: _debug | ||
|  | #      PURPOSE: Prints debug reports | ||
|  | #   PARAMETERS: $active         Boolean: 1 for print, 0 for no print | ||
|  | #               @messages       List of messages to print | ||
|  | #      RETURNS: Nothing | ||
|  | #  DESCRIPTION: Outputs messages to STDERR if $active is true. For each | ||
|  | #               message it removes any trailing newline and then adds one in | ||
|  | #               the 'print' so the caller doesn't have to bother. Prepends | ||
|  | #               each message with 'D> ' to show it's a debug message. | ||
|  | #       THROWS: No exceptions | ||
|  | #     COMMENTS: None | ||
|  | #     SEE ALSO: N/A | ||
|  | #=============================================================================== | ||
|  | sub _debug { | ||
|  |     my ( $active, @messages ) = @_; | ||
|  | 
 | ||
|  |     return unless $active; | ||
|  |     foreach my $msg (@messages) { | ||
|  |         chomp($msg); | ||
|  |         print STDERR "D> $msg\n"; | ||
|  |     } | ||
|  | } | ||
|  | 
 | ||
|  | #===  FUNCTION  ================================================================ | ||
|  | #         NAME: Options | ||
|  | #      PURPOSE: Processes command-line options | ||
|  | #   PARAMETERS: $optref     Hash reference to hold the options | ||
|  | #      RETURNS: Undef | ||
|  | #  DESCRIPTION: | ||
|  | #       THROWS: no exceptions | ||
|  | #     COMMENTS: none | ||
|  | #     SEE ALSO: n/a | ||
|  | #=============================================================================== | ||
|  | sub Options { | ||
|  |     my ($optref) = @_; | ||
|  | 
 | ||
|  |     my @options = ( | ||
|  |         "help",     "documentation|manpage", | ||
|  |         "debug=i",  "dry-run!", | ||
|  |         "verbose+", "verify!", | ||
|  |         "live!",    "json!", | ||
|  |         "config=s", | ||
|  |     ); | ||
|  | 
 | ||
|  |     if ( !GetOptions( $optref, @options ) ) { | ||
|  |         pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 ); | ||
|  |     } | ||
|  | 
 | ||
|  |     return; | ||
|  | } | ||
|  | 
 | ||
|  | __END__ | ||
|  | 
 | ||
|  | 
 | ||
|  | #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% | ||
|  | #  Application Documentation | ||
|  | #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% | ||
|  | #{{{ | ||
|  | 
 | ||
|  | =head1 NAME | ||
|  | 
 | ||
|  | process_comments | ||
|  | 
 | ||
|  | =over 4 | ||
|  | 
 | ||
|  | Process incoming comment files as email messages or JSON files | ||
|  | 
 | ||
|  | =back | ||
|  | 
 | ||
|  | =head1 VERSION | ||
|  | 
 | ||
|  | This documentation refers to process_comments version 0.3.3 | ||
|  | 
 | ||
|  | 
 | ||
|  | =head1 USAGE | ||
|  | 
 | ||
|  |     ./process_comments [-help] [-doc] [-debug=N] [-[no]dry-run] | ||
|  |         [-verbose ...] [-[no]live] [-[no]json] [-config=FILE] | ||
|  | 
 | ||
|  |     ./process_comments -dry-run | ||
|  |     ./process_comments -debug=3 -dry-run | ||
|  |     ./process_comments -verbose -verbose | ||
|  |     ./process_comments -help | ||
|  |     ./process_comments -documentation | ||
|  |     ./process_comments -json | ||
|  |     ./process_comments -verb -live -config=.process_comments_live.cfg | ||
|  | 
 | ||
|  | =head1 OPTIONS | ||
|  | 
 | ||
|  | =over 8 | ||
|  | 
 | ||
|  | =item B<-help> | ||
|  | 
 | ||
|  | Prints a brief help message describing the usage of the program, and then exits. | ||
|  | 
 | ||
|  | =item B<-documentation> or B<-manpage> | ||
|  | 
 | ||
|  | Prints the entire embedded documentation for the program, then exits. | ||
|  | 
 | ||
|  | =item B<-debug=N> | ||
|  | 
 | ||
|  | Enables debugging mode when N > 0 (zero is the default). The levels are: | ||
|  | 
 | ||
|  | =over 4 | ||
|  | 
 | ||
|  | =item B<1> | ||
|  | 
 | ||
|  | Reports on the options chosen. Running with the B<-dry-run> option prevents | ||
|  | problems with comments if any options have been incorrectly used. | ||
|  | 
 | ||
|  | =item B<2> | ||
|  | 
 | ||
|  | Prints all of the information described at the previous level. | ||
|  | 
 | ||
|  | Prints the names of files found in the mail or JSON spool areas. | ||
|  | 
 | ||
|  | Prints the internal details of the email, listing the MIME parts (if there are any). | ||
|  | 
 | ||
|  | Prints the length of the MIME part  matching the desired type, in lines. | ||
|  | 
 | ||
|  | Prints the entirety of the internal structure holding details of the mail file | ||
|  | and the comment it contains. This follows the moderation pass. | ||
|  | 
 | ||
|  | Prints the SQL that has been constructed to update the database. | ||
|  | 
 | ||
|  | =item B<3> | ||
|  | 
 | ||
|  | Prints all of the information described at the previous levels. | ||
|  | 
 | ||
|  | Prints the contents of the B<%config> hash built from the configuration file. | ||
|  | 
 | ||
|  | =back | ||
|  | 
 | ||
|  | =item B<-[no]dry-run> | ||
|  | 
 | ||
|  | Controls the program's B<dry-run> mode. It is off by default. In dry-run mode | ||
|  | the program reports what it would do but makes no changes. When off the | ||
|  | program makes all the changes it is designed to perform. | ||
|  | 
 | ||
|  | =item B<-verbose> | ||
|  | 
 | ||
|  | This option may be repeated. For each repetition the level of verbosity is | ||
|  | increased. By default no verbosity is in effect and the program prints out the | ||
|  | minimal amount of information. | ||
|  | 
 | ||
|  | Verbosity levels: | ||
|  | 
 | ||
|  | =over 4 | ||
|  | 
 | ||
|  | =item B<1> | ||
|  | 
 | ||
|  | Prints the name of each mail (or JSON) file as it's processed. | ||
|  | 
 | ||
|  | Prints any error messages during message validation, which are also being | ||
|  | logged (unless in dry-run mode) and saved for reporting later. | ||
|  | 
 | ||
|  | Prints a notification if the comment is added to the database (or that this | ||
|  | would have happened in dry-run mode). | ||
|  | 
 | ||
|  | Prints messages about the moving of each mail (or JSON) file from the | ||
|  | processing area, along with any errors accumulated for that file. In dry-run | ||
|  | mode simply indicates what would have happened. | ||
|  | 
 | ||
|  | Prints the response code received from the server when invoking the interface | ||
|  | for updating comment files there. If in dry-run mode the message produced | ||
|  | merely indicates what would have happened. | ||
|  | 
 | ||
|  | If validation failed earlier on then further information is produced about the | ||
|  | final actions taken on these files. | ||
|  | 
 | ||
|  | =item B<2> | ||
|  | 
 | ||
|  | Prints the addresses each mail message is being sent to (unless in JSON mode). | ||
|  | 
 | ||
|  | =item B<3> | ||
|  | 
 | ||
|  | Prints the JSON contents of each mail message (or of each JSON file). | ||
|  | 
 | ||
|  | =back | ||
|  | 
 | ||
|  | =item B<-[no]verify> | ||
|  | 
 | ||
|  | This option controls whether the script imposes a delay on comments. The idea | ||
|  | is that if the comment feature is used to rant on a subject or to pass | ||
|  | misinformation, delaying them will help to defuse the situation. | ||
|  | 
 | ||
|  | The default state is B<-noverify>; a delay is not imposed. Selecting | ||
|  | B<-verify> means that comments have to be at least 24 hours old before they | ||
|  | are processed. The length of the delay cannot currently be changed without | ||
|  | altering the script. | ||
|  | 
 | ||
|  | This feature of the script has hardly been used and is perhaps against the | ||
|  | spirit of HPR, so it will probably be removed in a future version. | ||
|  | 
 | ||
|  | =item B<-[no]live> | ||
|  | 
 | ||
|  | This option determines whether the program runs in live mode or not. The | ||
|  | default varies depending on which system it is being run on. | ||
|  | 
 | ||
|  | IT SHOULD NOT USUALLY BE NECESSARY TO USE THIS! | ||
|  | 
 | ||
|  | In live mode the program makes changes to the live database and sends messages | ||
|  | to the live web interface when a comment has been processed. With live mode | ||
|  | off the program assumes it is writing to a clone of the database and it does | ||
|  | not inform the webserver that a comment has been processed. | ||
|  | 
 | ||
|  | The default for the copy of the program on borg is that live mode is ON. | ||
|  | Otherwise the default is that live mode is OFF. The setting is determined by | ||
|  | the sed script called B<fixup.sed> on borg. This needs to be run whenever | ||
|  | a new version of the program is released. This is done as follows: | ||
|  | 
 | ||
|  |     sed -i -f fixup.sed process_comments | ||
|  | 
 | ||
|  | =item B<-[no]json> | ||
|  | 
 | ||
|  | This option selects JSON mode, which makes the script behave in a different | ||
|  | way from the default mode (B<-nojson> or MAIL mode) where it processes email | ||
|  | containing comments. | ||
|  | 
 | ||
|  | In JSON mode the script looks in a sub-directory called B<json/> where it | ||
|  | expects to find JSON files. The normal way in which these files arrive in this | ||
|  | directory is by using B<scp> to copy them from the HPR server (the directory | ||
|  | is B</home/hpr/comments>). This is a provision in case the normal route of | ||
|  | sending out email messages has failed for some reason. It also saves the user | ||
|  | from setting up the mail handling infrastructure that would otherwise be | ||
|  | needed. | ||
|  | 
 | ||
|  | In JSON mode the mail handling logic is not invoked, files are searched for in | ||
|  | the B<json/> directory and each file is processed, moderation is requested and | ||
|  | the comment is added to the database. In `B<-live>` mode the server is informed | ||
|  | that the comment has been processed. | ||
|  | 
 | ||
|  | The B<json/> directory needs to have three sub-directories: B<processed>, | ||
|  | B<banned> and B<rejected>. The script will place the processed files into | ||
|  | these sub-directories according to the moderation choice made. This makes it | ||
|  | easier to see what actions were taken and helps avoid repeated processing of | ||
|  | the same comment. | ||
|  | 
 | ||
|  | =item B<-config=FILE> | ||
|  | 
 | ||
|  | This option defines a configuration file other than the default | ||
|  | B<.hpr_db.cfg>. The file must be formatted as described below in the section | ||
|  | B<CONFIGURATION AND ENVIRONMENT>. | ||
|  | 
 | ||
|  | =back | ||
|  | 
 | ||
|  | =head1 DESCRIPTION | ||
|  | 
 | ||
|  | A script to process new comments, moderate them and add them to the HPR | ||
|  | database. | ||
|  | 
 | ||
|  | In the new HPR comment system (released September 2017) a new web form is | ||
|  | presented in association with each show. The form can be used to submit | ||
|  | a comment on the show in question and takes some standard fields: the name of | ||
|  | the commenter, the title of the comment and the body of the comment itself. | ||
|  | 
 | ||
|  | Once the comment has been submitted its contents are formatted as a JSON | ||
|  | object and are sent as a mail attachment to the address | ||
|  | B<comments@hackerpublicradio.org>. | ||
|  | 
 | ||
|  | Recipients of these mail messages can then perform actions on the comments | ||
|  | they contain to cause them to be added to the HPR database. These actions are: | ||
|  | approve the comment, block it (because it is inappropriate or some form of | ||
|  | Spam and we want to prevent any further messages from the associated IP | ||
|  | address), or reject it (delete it). There is also an ignore option which skips | ||
|  | the current comment in this run of the script so it can be revisited later. | ||
|  | 
 | ||
|  | This script can process an entire email message which has been saved to a file | ||
|  | or a file containing the JSON object (as in the email attachment). When | ||
|  | processing email it is expected that it will be found in a maildrop directory, | ||
|  | and when finished the messages will be placed in sub-directories according to | ||
|  | what actions were carried out. A similar logic is used for JSON files; they | ||
|  | are expected to be in a drop area and are moved to sub-directories after | ||
|  | processing. | ||
|  | 
 | ||
|  | The directories used as spool areas are defined in the configuration file(s) | ||
|  | described below. | ||
|  | 
 | ||
|  | =head2 CHANGES | ||
|  | 
 | ||
|  | =head3 2024 | ||
|  | 
 | ||
|  | An alternative method of comment approval has been added using PHP on the | ||
|  | server B<https://hub.hackerpublicradio.org>. This script was originally | ||
|  | designed around receiving email messages containing JSON or collecting JSON | ||
|  | files. The assumption was that it was the sole agent processing these messages | ||
|  | or files and would therefore be the only way that comments could enter the | ||
|  | database. | ||
|  | 
 | ||
|  | With the new server route it became possible for an emailed comment to be | ||
|  | received when that comment was already in the database. | ||
|  | 
 | ||
|  | As of version B<0.3.2> the script has been enhanced to check whether comments | ||
|  | received as mail messages (or JSON files) have already been processed. If this | ||
|  | is found to be the case the incoming data is skipped and stored in the | ||
|  | 'B<processed>' directory (because the comment has been approved elsewhere). | ||
|  | 
 | ||
|  | There is still scope for confusion - if for example, a comment has been | ||
|  | rejected on the server, the script will see it as a potential addition to the | ||
|  | database. The moderators need to coordinate their decisions! | ||
|  | 
 | ||
|  | The ultimate solution will be to have just one way of moderating and | ||
|  | installing comments which is available to any of the "Janitors". | ||
|  | 
 | ||
|  | =head2 MAIL HANDLING | ||
|  | 
 | ||
|  | One way of handling incoming mail is to use a mail client which is capable of | ||
|  | saving messages sent to the above address in the spool area mentioned earlier. | ||
|  | For example, Thunderbird can do this by use of a filter and a plugin. Other | ||
|  | MUA's will have similar capabilities. | ||
|  | 
 | ||
|  | When this script is run on the mail spool area it will process all of the | ||
|  | files it finds. For each file it will check its validity in various ways, | ||
|  | display the comment then offer a moderation menu. The moderation options are | ||
|  | described below. | ||
|  | 
 | ||
|  | =head3 APPROVE | ||
|  | 
 | ||
|  | If a comment is approved then it will be added to the database, the associated | ||
|  | mail file will be moved to a sub-directory (by default called 'B<processed>'), | ||
|  | and the HPR server will be notified of this action. | ||
|  | 
 | ||
|  | =head3 BAN | ||
|  | 
 | ||
|  | If a comment is banned then it will not be added to the database. The mail | ||
|  | file will be moved to the sub-directory 'B<banned>' and the HPR server will be | ||
|  | informed that the IP address associated with the comment should be placed on | ||
|  | a black list. | ||
|  | 
 | ||
|  | =head3 REJECT | ||
|  | 
 | ||
|  | If a comment is rejected it is not written to the database, the mail file is | ||
|  | moved to the sub-directory 'B<rejected>' and the HPR server informed that the | ||
|  | comment can be deleted. | ||
|  | 
 | ||
|  | =head3 IGNORE | ||
|  | 
 | ||
|  | If a comment is ignored it is simply left in the mail spool and no further | ||
|  | processing done on it. It will be eligible for processing again when the | ||
|  | script is next run. | ||
|  | 
 | ||
|  | =head2 JSON FILE HANDLING | ||
|  | 
 | ||
|  | As described under the description of the B<-[no]json> option, the script | ||
|  | allows the processing of a multiple JSON files, each containing a single | ||
|  | comment. These files are expected to be in another spool area (defined int he | ||
|  | configuration file). The JSON is checked and all of the comment fields are | ||
|  | validated, then the moderation process is begun. | ||
|  | 
 | ||
|  | Moderation in this case consists of the same steps as described above except | ||
|  | that no mail file actions are taken and the JSON file is moved to | ||
|  | a sub-directory after processing. | ||
|  | 
 | ||
|  | =head1 DIAGNOSTICS | ||
|  | 
 | ||
|  | =over 4 | ||
|  | 
 | ||
|  | =item B<Unable to find configuration file ...> | ||
|  | 
 | ||
|  | Type: fatal | ||
|  | 
 | ||
|  | The nominated configuration file referenced in B<-config=FILE> was not found. | ||
|  | 
 | ||
|  | =item B<No mail found; nothing to do> | ||
|  | 
 | ||
|  | Type: fatal | ||
|  | 
 | ||
|  | No mail files were found in the mail spool area requiring processing. | ||
|  | 
 | ||
|  | =item B<No JSON files found; nothing to do> | ||
|  | 
 | ||
|  | Type: fatal | ||
|  | 
 | ||
|  | No JSON files were found in the JSON spool area requiring processing. | ||
|  | 
 | ||
|  | =item B<Failed to read JSON file '...' ...> | ||
|  | 
 | ||
|  | Type: fatal | ||
|  | 
 | ||
|  | A JSON file in the spool area could not be read with a JSON parser. | ||
|  | 
 | ||
|  | =item B<Failed to parse comment timestamp ...> | ||
|  | 
 | ||
|  | Type: fatal | ||
|  | 
 | ||
|  | The timestamp must be converted to a format compatible with MySQL/MariaDB but | ||
|  | during this process the parse failed. | ||
|  | 
 | ||
|  | =item B<Failed to open input file '...' ...> | ||
|  | 
 | ||
|  | Type: fatal | ||
|  | 
 | ||
|  | A mail file in the spool area could not be opened. | ||
|  | 
 | ||
|  | =item B<Failed to move ...> | ||
|  | 
 | ||
|  | Type: warning | ||
|  | 
 | ||
|  | A mail file could not be moved to the relevant sub-directory. | ||
|  | 
 | ||
|  | =item B<Failed to close input file '...' ...> | ||
|  | 
 | ||
|  | Type: warning | ||
|  | 
 | ||
|  | A mail file in the spool area could not be closed. | ||
|  | 
 | ||
|  | =item B<Various error messages from the database subsystem> | ||
|  | 
 | ||
|  | Type: fatal, warning | ||
|  | 
 | ||
|  | An action on the database has been flagged as an error. | ||
|  | 
 | ||
|  | =item B<Various error messages from the Template toolkit> | ||
|  | 
 | ||
|  | Type: fatal | ||
|  | 
 | ||
|  | An action relating to the template used for the display of the comment has | ||
|  | been flagged as an error. | ||
|  | 
 | ||
|  | =item B<Invalid call to 'call_back' subroutine; missing key> | ||
|  | 
 | ||
|  | Type: warning | ||
|  | 
 | ||
|  | The routine 'call_back' was called incorrectly. The key was missing. | ||
|  | 
 | ||
|  | =item B<Invalid call to 'call_back' subroutine; invalid action> | ||
|  | 
 | ||
|  | Type: warning | ||
|  | 
 | ||
|  | The routine 'call_back' was called incorrectly. The action was invalid. | ||
|  | 
 | ||
|  | =item B<Error from remote server indicating failure> | ||
|  | 
 | ||
|  | Type: warning | ||
|  | 
 | ||
|  | While attempting to send an action to the remote server with the 'call_back' | ||
|  | subroutine an error message was received. | ||
|  | 
 | ||
|  | =back | ||
|  | 
 | ||
|  | =head1 CONFIGURATION AND ENVIRONMENT | ||
|  | 
 | ||
|  | =head2 CONFIGURATION | ||
|  | 
 | ||
|  | The configuration file design was changed for version B<0.2.7> and beyond. | ||
|  | 
 | ||
|  | The script expects a configuration file to provide it with various program | ||
|  | settings, database credentials and HPR CMS credentials. | ||
|  | 
 | ||
|  | The default configuration file name is B<.process_comments.cfg> and is for use | ||
|  | with the local clone of the hpr database. | ||
|  | 
 | ||
|  | This file has the general structure: | ||
|  | 
 | ||
|  |  <<include .process_comments_settings.cfg>> | ||
|  | 
 | ||
|  |  <<include .hpr_db.cfg>> | ||
|  | 
 | ||
|  |  <cms> | ||
|  |      user = "dummy:dummy" | ||
|  |  </cms> | ||
|  | 
 | ||
|  | It uses B<Config::General> 'B<include>' directives to include the file | ||
|  | B<.process_comments_settings.cfg> which holds the program settings. It then | ||
|  | includes the file B<.hpr_db.cfg> which containss credentials for the cloned | ||
|  | database. Finally it defines dummy credentials for the HPR CMS since this | ||
|  | feature is not used by default. | ||
|  | 
 | ||
|  | The more useful configuration is in B<.process_comments_live.cfg>: | ||
|  | 
 | ||
|  |  <<include .process_comments_settings.cfg>> | ||
|  | 
 | ||
|  |  <<include .hpr_livedb.cfg>> | ||
|  | 
 | ||
|  |  <cms> | ||
|  |     <<include .hpradmin_curlrc>> | ||
|  |  </cms> | ||
|  | 
 | ||
|  | The basic format of the main configuration data after the inclusions have been | ||
|  | performed is then: | ||
|  | 
 | ||
|  |  <settings> | ||
|  |     # Holds program settings | ||
|  |  </settings> | ||
|  |  <database> | ||
|  |     # Holds database settings | ||
|  |  </database> | ||
|  |  <cms> | ||
|  |     # Holds CMS credentials | ||
|  |  </cms> | ||
|  | 
 | ||
|  | =head3 CONTENTS OF B<.process_comments_settings.cfg>: | ||
|  | 
 | ||
|  | This section is enclosed in '<settings>' tags: | ||
|  | 
 | ||
|  |  <settings> | ||
|  |      PROG       = process_comments | ||
|  | 
 | ||
|  |      # | ||
|  |      # Defaults | ||
|  |      # | ||
|  |      basedir    = "$HOME/HPR/Comment_system" | ||
|  |      configfile = "$basedir/.hpr_db.cfg" | ||
|  |      logfile    = "$basedir/logs/${PROG}.log" | ||
|  |      template   = "$basedir/${PROG}.tpl" | ||
|  | 
 | ||
|  |      # | ||
|  |      # Mail message stash area | ||
|  |      # | ||
|  |      maildrop   = "$HOME/HPR/CommentDrop" | ||
|  |      processed  = "$maildrop/processed" | ||
|  |      rejected   = "$maildrop/rejected" | ||
|  |      banned     = "$maildrop/banned" | ||
|  | 
 | ||
|  |      # | ||
|  |      # JSON stash area | ||
|  |      # | ||
|  |      jsondir    = "$basedir/json" | ||
|  |      jprocessed = "$jsondir/processed" | ||
|  |      jrejected  = "$jsondir/rejected" | ||
|  |      jbanned    = "$jsondir/banned" | ||
|  | 
 | ||
|  |      # | ||
|  |      # How to tell the server the comment's processed | ||
|  |      # | ||
|  |      callback_template = \ | ||
|  |              "https://hackerpublicradio.org/comment_process.php?key=%s&action=%s" | ||
|  | 
 | ||
|  |  </settings> | ||
|  | 
 | ||
|  | =head3 CONTENTS OF B<.hpr_db.cfg> AND B<.hpr_livedb.cfg> | ||
|  | 
 | ||
|  | The default file providing this part is just for use with the default | ||
|  | database. The section is enclosed in '<database>' tags: | ||
|  | 
 | ||
|  |  <database> | ||
|  |      host = 127.0.0.1 | ||
|  |      port = PORT | ||
|  |      name = DATABASE | ||
|  |      user = USERNAME | ||
|  |      password = PASSWORD | ||
|  |  </database> | ||
|  | 
 | ||
|  | These settings can alternatively be used to connect to an SSH tunnel which has | ||
|  | been connected  from a remote system (like borg) to the live database. | ||
|  | Assuming the port chosen for this is 3307 something like the following could | ||
|  | be used: | ||
|  | 
 | ||
|  |  <database> | ||
|  |      host = 127.0.0.1 | ||
|  |      port = 3307 | ||
|  |      name = hpr_hpr | ||
|  |      user = hpr_hpr | ||
|  |      password = "**censored**" | ||
|  |  </database> | ||
|  | 
 | ||
|  | A typical Bash script for opening a tunnel might be: | ||
|  | 
 | ||
|  |  #!/bin/bash | ||
|  |  SSHPORT=22 | ||
|  |  LOCALPORT=3307 | ||
|  |  REMOTEPORT=3306 | ||
|  |  ssh -p ${SSHPORT} -f -N -L localhost:${LOCALPORT}:localhost:${REMOTEPORT} hpr@hackerpublicradio.org | ||
|  | 
 | ||
|  | =head3 CONTENTS OF B<.hpradmin_curlrc> | ||
|  | 
 | ||
|  | This is a file used by the 'curl' command to contain credentials to access the | ||
|  | CMS on the HPR server. The format needs to be: | ||
|  | 
 | ||
|  |  user = hpradmin:**censored** | ||
|  | 
 | ||
|  | This is one of the formats allowed by 'curl' and is also compatible with the | ||
|  | B<Config::General> module used by this script. | ||
|  | 
 | ||
|  | =head2 TEMPLATE | ||
|  | 
 | ||
|  | The program displays the comment that is currently being processed for | ||
|  | moderation. It uses a template along with the Perl B<Template> module to do | ||
|  | this. By default this template is called B<process_comments.tpl>. This can be | ||
|  | changed by changing the line in the "settings" configuration file which sets | ||
|  | the 'template' variable. | ||
|  | 
 | ||
|  | The template is provided with the following data: | ||
|  | 
 | ||
|  |     file        a scalar containing the name of the file being processed | ||
|  | 
 | ||
|  |     db          a hash containing the details of the show to which the | ||
|  |                 comment relates, returned from a database query: | ||
|  |                 id              the episode number | ||
|  |                 date            the date of the episode | ||
|  |                 title           the episode title | ||
|  |                 host            the host name | ||
|  | 
 | ||
|  |     comment     a hash containing the fields from the comment: | ||
|  |                 eps_id                  the episode number | ||
|  |                 comment_timestamp       date and time of the comment | ||
|  |                 comment_author_name     comment author | ||
|  |                 comment_title           comment title | ||
|  |                 comment_text            comment text | ||
|  |                 justification           justification for posting (if | ||
|  |                                         relevant) | ||
|  |                 key                     unique comment key | ||
|  | 
 | ||
|  | =head1 DEPENDENCIES | ||
|  | 
 | ||
|  |     Carp | ||
|  |     Config::General | ||
|  |     DBI | ||
|  |     Data::Dumper | ||
|  |     DateTime::Format::ISO8601 | ||
|  |     Encode | ||
|  |     File::Copy | ||
|  |     File::Find::Rule | ||
|  |     File::Slurper | ||
|  |     Getopt::Long | ||
|  |     HTML::Entities | ||
|  |     HTML::Restrict | ||
|  |     IO::Prompter | ||
|  |     JSON | ||
|  |     LWP::UserAgent | ||
|  |     List::Util | ||
|  |     Log::Handler | ||
|  |     MIME::Parser | ||
|  |     Mail::Address | ||
|  |     Mail::Field | ||
|  |     Mail::Internet | ||
|  |     Pod::Usage | ||
|  |     SQL::Abstract | ||
|  |     Template | ||
|  | 
 | ||
|  | The Perl B<experimental:try> option is used instead of the original | ||
|  | B<Try::Tiny> package. | ||
|  | 
 | ||
|  | =head1 BUGS AND LIMITATIONS | ||
|  | 
 | ||
|  | There are no known bugs in this module. | ||
|  | Please report problems to Dave Morriss (Dave.Morriss@gmail.com) | ||
|  | Patches are welcome. | ||
|  | 
 | ||
|  | =head1 AUTHOR | ||
|  | 
 | ||
|  | Dave Morriss (Dave.Morriss@gmail.com) | ||
|  | 
 | ||
|  | =head1 LICENCE AND COPYRIGHT | ||
|  | 
 | ||
|  | Copyright (c) 2017 - 2024 Dave Morriss (Dave.Morriss@gmail.com). All rights | ||
|  | reserved. | ||
|  | 
 | ||
|  | This module is free software; you can redistribute it and/or | ||
|  | modify it under the same terms as Perl itself. See perldoc perlartistic. | ||
|  | 
 | ||
|  | 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. | ||
|  | 
 | ||
|  | =cut | ||
|  | 
 | ||
|  | #}}} | ||
|  | 
 | ||
|  | # [zo to open fold, zc to close] | ||
|  | 
 | ||
|  | # vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker |