forked from HPR/hpr-tools
		
	
		
			
				
	
	
		
			2344 lines
		
	
	
		
			77 KiB
		
	
	
	
		
			Perl
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			2344 lines
		
	
	
		
			77 KiB
		
	
	
	
		
			Perl
		
	
	
		
			Executable File
		
	
	
	
	
#!/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
 |