File Coverage

blib/lib/App/SpamcupNG.pm
Criterion Covered Total %
statement 188 249 75.5
branch 57 122 46.7
condition 7 27 25.9
subroutine 20 20 100.0
pod 3 3 100.0
total 275 421 65.3


line stmt bran cond sub pod time code
1             package App::SpamcupNG;
2 5     5   466140 use warnings;
  5         52  
  5         165  
3 5     5   27 use strict;
  5         19  
  5         134  
4 5     5   2910 use HTML::Form 6.07;
  5         172775  
  5         156  
5 5     5   10320 use Getopt::Std;
  5         273  
  5         346  
6 5     5   2222 use YAML::XS 0.62 qw(LoadFile);
  5         14502  
  5         279  
7 5     5   37 use File::Spec;
  5         10  
  5         156  
8 5     5   2862 use Hash::Util qw(lock_hash);
  5         14851  
  5         31  
9 5     5   490 use Exporter 'import';
  5         14  
  5         182  
10 5     5   4295 use Log::Log4perl 1.54 qw(get_logger :levels);
  5         234747  
  5         35  
11 5     5   784 use Carp qw(confess);
  5         23  
  5         309  
12              
13             use App::SpamcupNG::HTMLParse (
14 5         592 'find_next_id', 'find_errors',
15             'find_warnings', 'find_spam_header',
16             'find_best_contacts', 'find_receivers',
17             'find_message_age', 'find_header_info'
18 5     5   2987 );
  5         16  
19 5     5   2300 use App::SpamcupNG::Summary;
  5         12  
  5         15  
20 5     5   2425 use App::SpamcupNG::UserAgent;
  5         17  
  5         213  
21 5     5   2614 use App::SpamcupNG::Summary::Recorder;
  5         23  
  5         282  
22              
23 5     5   49 use constant TARGET_HTML_FORM => 'sendreport';
  5         12  
  5         13219  
24              
25             our @EXPORT_OK
26             = qw(read_config main_loop %OPTIONS_MAP config_logger TARGET_HTML_FORM);
27             our %OPTIONS_MAP = (
28             'check_only' => 'n',
29             'all' => 'a',
30             'stupid' => 's',
31             'alt_code' => 'c',
32             'alt_user' => 'l',
33             'verbosity' => 'V',
34             'database' => { enabled => 0 }
35             );
36              
37             my %regexes = (
38             no_user_id => qr/\>No userid found\</i,
39             next_id => qr/sc\?id\=(.*?)\"\>/i,
40             );
41              
42             lock_hash(%OPTIONS_MAP);
43              
44             our $VERSION = '0.017'; # VERSION
45              
46             =head1 NAME
47              
48             App::SpamcupNG - module to export functions for spamcup program
49              
50             =head1 SYNOPSIS
51              
52             use App::SpamcupNG qw(read_config get_browser main_loop config_logger %OPTIONS_MAP);
53              
54             =head1 DESCRIPTION
55              
56             App-SpamcupNG is a Perl web crawler for finishing Spamcop.net reports
57             automatically. This module implements the functions used by the spamcup
58             program.
59              
60             See the README.md file on this project for more details.
61              
62             =head1 EXPORTS
63              
64             =head2 read_config
65              
66             Reads a YAML file, sets the command line options and return the associated
67             accounts.
68              
69             Expects as parameter a string with the full path to the YAML file and a hash
70             reference of the command line options read (as returned by L<Getopts::Std>
71             C<getopts> function).
72              
73             The hash reference options will set as defined in the YAML file. Options
74             defined in the YAML have preference of those read on the command line then.
75              
76             It will also return all data configured in the C<Accounts> section of the YAML
77             file as a hash refence. Check the README.md file for more details about the
78             configuration file.
79              
80             =cut
81              
82             sub read_config {
83 4     4 1 4364 my ( $cfg, $cmd_opts ) = @_;
84 4         14 my $data = LoadFile($cfg);
85 2 100       405 confess 'second parameter must be a hash reference'
86             unless ( ref($cmd_opts) eq 'HASH' );
87              
88             # sanity checking
89 1         2 for my $opt ( keys( %{ $data->{ExecutionOptions} } ) ) {
  1         8  
90             confess
91             "'$opt' is not a valid option for configuration files. Check the documentation."
92 7 50       23 unless ( exists( $OPTIONS_MAP{$opt} ) );
93             }
94              
95 1         10 for my $opt ( keys(%OPTIONS_MAP) ) {
96              
97 7 100       21 if ( $opt eq 'database' ) {
98 1         7 $cmd_opts->{$opt} = $data->{ExecutionOptions}->{$opt};
99 1         3 next;
100             }
101              
102 6 100       15 if ( $opt eq 'verbosity' ) {
103 1         5 $cmd_opts->{'V'} = $data->{ExecutionOptions}->{$opt};
104 1         2 next;
105             }
106              
107 5 100 66     20 if ( exists( $data->{ExecutionOptions}->{$opt} )
108             and ( $data->{ExecutionOptions}->{$opt} eq 'y' ) )
109             {
110 2         6 $cmd_opts->{$opt} = 1;
111             }
112             else {
113 3         7 $cmd_opts->{$opt} = 0;
114             }
115              
116             }
117              
118 1         6 return $data->{Accounts};
119             }
120              
121             sub _report_form {
122 3     3   13534 my ( $html_ref, $base_uri ) = @_;
123 3 50       129 die 'Must receive an scalar reference of the HTML response'
124             unless ( ref($html_ref) );
125              
126 3         35 my @forms = HTML::Form->parse( $$html_ref, $base_uri );
127              
128 3         53042 foreach my $form (@forms) {
129 5         22 my $name = $form->attr('name');
130 5 100       64 next unless defined($name);
131 2 50       39 return $form if ( $name eq TARGET_HTML_FORM );
132             }
133              
134 1         34 return undef;
135             }
136              
137             =pod
138              
139             =head2 config_logger
140              
141             Configures a L<Log::Log4perl> object, as defined by the verbosity parameter (-V
142             in the command line).
143              
144             Expected parameters:
145              
146             =over
147              
148             =item *
149              
150             level
151              
152             =item *
153              
154             path to a log file
155              
156             =back
157              
158             If the verbosity is set to DEBUG, all messages will be sent to a log file
159             opened as C<spamcup.log> in append mode.
160              
161             Otherwise, all messages will be sent to C<STDOUT>.
162              
163             Verbosity modes are:
164              
165             =over
166              
167             =item *
168              
169             DEBUG
170              
171             =item *
172              
173             INFO
174              
175             =item *
176              
177             WARN
178              
179             =item *
180              
181             ERROR
182              
183             =item *
184              
185             FATAL
186              
187             =back
188              
189             Depending on the verbosity level, more or less information you be provided. See
190             L<Log::Log4perl> for more details about the levels.
191              
192             =cut
193              
194             sub config_logger {
195 1     1 1 671 my ( $level, $log_file ) = @_;
196 1 50 33     12 confess "Must receive a string for the level parameter"
197             unless ( ( defined($level) ) and ( $level ne '' ) );
198 1 50 33     8 confess "Must receive a string for the log file parameter"
199             unless ( ( defined($log_file) ) and ( $log_file ne '' ) );
200              
201             # :TODO:21/01/2018 12:07:01:ARFREITAS: Do we need to import :levels from Log::Log4perl at all?
202 1         11 my %levels = (
203             DEBUG => $DEBUG,
204             INFO => $INFO,
205             WARN => $WARN,
206             ERROR => $ERROR,
207             FATAL => $FATAL
208             );
209             confess "The value '$level' is not a valid value for level"
210 1 50       5 unless ( exists( $levels{$level} ) );
211              
212 1         2 my $conf;
213              
214 1 50       5 if ( $level eq 'DEBUG' ) {
215 0         0 $conf = qq(
216             log4perl.category.SpamcupNG = DEBUG, Logfile
217             log4perl.appender.Logfile = Log::Log4perl::Appender::File
218             log4perl.appender.Logfile.filename = $log_file
219             log4perl.appender.Logfile.layout = Log::Log4perl::Layout::PatternLayout
220             log4perl.appender.Logfile.layout.ConversionPattern = [%d] - %p - %F %L - %m%n
221             );
222             }
223             else {
224 1         4 $conf = qq(
225             log4perl.category.SpamcupNG = $level, Screen
226             log4perl.appender.Screen = Log::Log4perl::Appender::Screen
227             log4perl.appender.Screen.stderr = 0
228             log4perl.appender.Screen.layout = Log::Log4perl::Layout::SimpleLayout
229             );
230             }
231              
232 1         11 Log::Log4perl::init( \$conf );
233             }
234              
235             sub _error_handling {
236 1     1   4 my $content_ref = shift;
237 1         13 my $logger = get_logger('SpamcupNG');
238              
239 1 50       117 if ( my $errors_ref = find_errors($content_ref) ) {
240              
241 1         3 foreach my $error ( @{$errors_ref} ) {
  1         6  
242 0 0       0 if ( $error->is_fatal() ) {
243 0         0 $logger->fatal($error->message());
244              
245             # must stop processing the HTML for this report and move to next
246 0         0 return 1;
247             }
248             else {
249 0         0 $logger->error($error->message());
250             }
251              
252             }
253              
254             }
255              
256 1         6 return 0;
257             }
258              
259             =pod
260              
261             =head2 main_loop
262              
263             Processes all the pending SPAM reports in a loop until finished.
264              
265             Expects as parameter (in this sequence):
266              
267             =over
268              
269             =item *
270              
271             a L<LWP::UserAgent> instance
272              
273             =item *
274              
275             A hash reference with the following key/values:
276              
277             =over
278              
279             =item *
280              
281             ident => The identity to Spamcop
282              
283             =item *
284              
285             pass => The password to Spamcop
286              
287             =item *
288              
289             delay => time in seconds to wait for next iteration with Spamcop website
290              
291             =item *
292              
293             verbosity => defines what level of information should be provided. Uses the
294             same values as defined by L<Log::Log4perl>.
295              
296             As confusing as it seems, current implementation may accept debug messages
297             B<and> disable other messages.
298              
299             =item *
300              
301             check_only => true (1) or false (0) to only check for unreported SPAM, but not
302             reporting them
303              
304             =back
305              
306             =back
307              
308             Returns true if everything went right, or C<die> if a fatal error happened.
309              
310             =cut
311              
312             sub main_loop {
313 1     1 1 3652 my ( $ua, $opts_ref ) = @_;
314 1         6 my $logger = get_logger('SpamcupNG');
315 1         48 binmode( STDOUT, ":utf8" );
316              
317             # last seen SPAM id
318 1         2 my $last_seen;
319              
320             # Get first page that contains link to next one...
321              
322 1 50       7 if ( $logger->is_debug ) {
323 0         0 $logger->debug( "Sleeping for " . $opts_ref->{delay} . ' seconds' );
324             }
325              
326 1         1000191 sleep $opts_ref->{delay};
327 1         47 my $response_ref = $ua->login( $opts_ref->{ident}, $opts_ref->{pass} );
328 1 50       412 return 0 if ( _error_handling($response_ref) );
329 1         9 $logger->info('Log in completed');
330 1         250 my $next_id;
331 1         18 my $summary = App::SpamcupNG::Summary->new;
332              
333 1 50       5 if ($response_ref) {
334 1         6 $next_id = find_next_id($response_ref);
335              
336 1 50       6 if ( $logger->is_debug ) {
337 0 0       0 $logger->debug("ID of next SPAM report found: $next_id")
338             if ($next_id);
339             }
340              
341 1         17 $summary->set_tracking_id($next_id);
342 1 50       25 return -1 unless ( defined($next_id) );
343             }
344             else {
345 0         0 return 0;
346             }
347              
348             # avoid loops
349 1 50 33     7 if ( ($last_seen) and ( $next_id eq $last_seen ) ) {
350 0         0 $logger->fatal(
351             'I have seen this ID earlier, we do not want to report it again.'
352             . 'This usually happens because of a bug in Spamcup.'
353             . 'Make sure you use latest version!'
354             . "You may also want to go check from Spamcop what is happening: http://www.spamcop.net/sc?id=$next_id"
355             );
356             }
357              
358 1         2 $last_seen = $next_id; # store for comparison
359              
360             # Fetch the SPAM report form
361 1 50       4 if ( $logger->is_debug ) {
362 0         0 $logger->debug( 'Sleeping for ' . $opts_ref->{delay} . ' seconds' );
363             }
364              
365 1         1000260 sleep $opts_ref->{delay};
366              
367             # Getting a SPAM report
368 1         51 $response_ref = $ua->spam_report($next_id);
369 1 50       683 return 0 unless ($response_ref);
370              
371 1 50       17 if ( my $age_info_ref = find_message_age($response_ref) ) {
372 1 50       6 if ($age_info_ref) {
373              
374 1 50       10 if ( $logger->is_info ) {
375 1         23 $logger->info( 'Message age: '
376             . $age_info_ref->[0]
377             . ', unit: '
378             . $age_info_ref->[1] );
379             }
380              
381 1         263 $summary->set_age( $age_info_ref->[0] );
382 1         24 $summary->set_age_unit( $age_info_ref->[1] );
383             }
384             else {
385 0         0 $logger->warn('Failed to parse SPAM age information');
386             }
387             }
388              
389 1 50       32 if ( my $warns_ref = find_warnings($response_ref) ) {
390              
391 1 50       4 if ( @{$warns_ref} ) {
  1         6  
392              
393 0         0 foreach my $warning ( @{$warns_ref} ) {
  0         0  
394 0         0 $logger->warn( $warning->message() );
395             }
396              
397             }
398             else {
399 1         8 $logger->info('No warnings found in response');
400             }
401             }
402              
403 1 50       202 if ( my $errors_ref = find_errors($response_ref) ) {
404              
405 1         5 foreach my $error ( @{$errors_ref} ) {
  1         4  
406 0 0       0 if ( $error->is_fatal() ) {
407 0         0 $logger->fatal( $error->message() );
408              
409             # must stop processing the HTML for this report and move to next
410 0         0 return 0;
411             }
412             else {
413 0         0 $logger->error( $error->message() );
414             }
415              
416             }
417              
418             }
419              
420             # parsing the SPAM
421 1         3 my $_cancel = 0;
422 1         9 my $base_uri = $ua->base();
423              
424 1 50       8 unless ($base_uri) {
425 0         0 $logger->fatal(
426             'No base URI found. Internal error? Please report this error by registering an issue on Github'
427             );
428             }
429              
430 1 50       8 if ( $logger->is_debug ) {
431 0         0 $logger->debug("Base URI is $base_uri");
432             }
433              
434 1         18 my $best_ref = find_best_contacts($response_ref);
435 1         39 $summary->set_contacts($best_ref);
436 1 50       25 if ( $logger->is_info ) {
437 1 50       13 if ( @{$best_ref} ) {
  1         5  
438 1         6 my $best_as_text = join( ', ', @$best_ref );
439 1         8 $logger->info("Best contacts for SPAM reporting: $best_as_text");
440             }
441             }
442              
443 1         234 my $form = _report_form( $response_ref, $base_uri );
444 1 50       8 $logger->fatal(
445             'Could not find the HTML form to report the SPAM! May be a temporary Spamcop.net error, try again later! Quitting...'
446             ) unless ( defined($form) );
447              
448 1         8 my $spam_header_info = find_header_info($response_ref);
449 1         11 $summary->set_mailer( $spam_header_info->{mailer} );
450 1         23 $summary->set_content_type( $spam_header_info->{content_type} );
451 1         16 $summary->set_charset( $spam_header_info->{charset} );
452              
453 1 50       19 if ( $logger->is_info ) {
454 1         16 $logger->info( 'X-Mailer: ' . $summary->to_text('mailer') );
455 1         218 $logger->info( 'Content-Type: ' . $summary->to_text('content_type') );
456              
457 1         100 my $spam_header_ref = find_spam_header($response_ref);
458              
459 1 50       7 if ($spam_header_ref) {
460 1         7 my $as_string = join( "\n", @$spam_header_ref );
461 1         6 $logger->info("Head of the SPAM follows:\n$as_string");
462             }
463             else {
464 0         0 $logger->warn('No SPAM header found');
465             }
466              
467             # how many recipients for reports
468 1         157 my $max = $form->value("max");
469 1         210 my $willsend;
470             my $wontsend;
471              
472             # iterate targets
473 1         6 for ( my $i = 1 ; $i <= $max ; $i++ ) {
474 1         7 my $send = $form->value("send$i");
475 1         129 my $type = $form->value("type$i");
476 1         116 my $master = $form->value("master$i");
477 1         128 my $info = $form->value("info$i");
478              
479             # convert %2E -style stuff back to text, if any
480 1 50       146 if ( $info =~ /%([A-Fa-f\d]{2})/g ) {
481 0         0 $info =~ s/%([A-Fa-f\d]{2})/chr hex $1/eg;
  0         0  
482             }
483              
484 1 50 33     10 if (
      33        
485             $send
486             and ( ( $send eq 'on' )
487             or ( $type =~ /^mole/ and $send == 1 ) )
488             )
489             {
490 1         8 $willsend .= "$master ($info)\n";
491             }
492             else {
493 0         0 $wontsend .= "$master ($info)\n";
494             }
495             }
496              
497 1         5 my $message
498             = 'Would send the report to the following addresses (reason in parenthesis): ';
499              
500 1 50       13 if ($willsend) {
501 1         5 $message .= $willsend;
502             }
503             else {
504 0         0 $message .= '--none--';
505             }
506              
507 1         10 $logger->info($message);
508 1         131 $message = 'Following addresses would not be used: ';
509              
510 1 50       17 if ($wontsend) {
511 0         0 $message .= $wontsend;
512             }
513             else {
514 1         3 $message .= '--none--';
515             }
516              
517 1         4 $logger->info($message);
518             }
519             else {
520 0 0 0     0 if ( ( $logger->is_debug ) and ($form) ) {
521 0         0 $logger->debug( 'Form data follows: ' . $form->dump );
522             }
523             }
524              
525 1 50       100 $logger->fatal(
526             'Could not find the HTML form to report the SPAM! May be a temporary Spamcop website error, try again later! Quitting...'
527             ) unless ($form);
528              
529             # Run without confirming each spam? Stupid. :)
530 1 50       5 unless ( $opts_ref->{stupid} ) {
531 0         0 print "* Are you sure this is SPAM? [y/N] ";
532              
533 0         0 my $reply = <>; # this should be done differently!
534 0 0 0     0 if ( $reply && $reply !~ /^y/i ) {
    0          
535 0         0 print "* Cancelled.\n";
536 0         0 $_cancel = 1; # mark to be cancelled
537             }
538             elsif ( !$reply ) {
539 0         0 print "* Accepted.\n";
540             }
541             else {
542 0         0 print "* Accepted.\n";
543             }
544             }
545             else {
546              
547             # little delay for automatic processing
548 1         1000343 sleep $opts_ref->{delay};
549             }
550              
551             # this happens rarely, but I've seen this; spamcop does not show preview headers for some reason
552 1 50       128 if ( $$response_ref =~ /Send Spam Report\(S\) Now/gi ) {
    0          
    0          
553              
554 1 50       33 unless ( $opts_ref->{stupid} ) {
555 0         0 print
556             "* Preview headers not available, but you can still report this. Are you sure this is SPAM? [y/N] ";
557              
558 0         0 my $reply = <>;
559 0         0 chomp($reply);
560              
561 0 0 0     0 if ( $reply && $reply !~ /^y/i ) {
562              
563             # not Y
564 0         0 print "* Cancelled.\n";
565 0         0 $_cancel = 1; # mark to be cancelled
566             }
567             else {
568              
569             # Y
570 0         0 print "* Accepted.\n";
571             }
572             }
573              
574             }
575             elsif ( $$response_ref
576             =~ /click reload if this page does not refresh automatically in \n(\d+) seconds/gs
577             )
578              
579             {
580 0         0 my $delay = $1;
581 0         0 $logger->warn(
582             "Spamcop seems to be currently overloaded. Trying again in $delay seconds. Wait..."
583             );
584 0         0 sleep $opts_ref->{delay};
585              
586             # fool it to avoid duplicate detector
587 0         0 $last_seen = 0;
588              
589             # fake that everything is ok
590 0         0 return 1;
591             }
592             elsif ( $$response_ref
593             =~ /No source IP address found, cannot proceed. Not full header/gs )
594             {
595 0         0 $logger->warn(
596             'No source IP address found. Your report might be missing headers. Skipping.'
597             );
598 0         0 return 0;
599             }
600             else {
601              
602             # Shit happens. If you know it should be parseable, please report a bug!
603 0         0 $logger->warn(
604             "Can't parse Spamcop.net's HTML. If this does not happen very often you can ignore this warning. Otherwise check if there's new version available. Skipping."
605             );
606 0         0 return 0;
607             }
608              
609 1 50       9 if ( $opts_ref->{check_only} ) {
610 0         0 $logger->info(
611             'You gave option -n, so we\'ll stop here. The SPAM was NOT reported.'
612             );
613 0         0 exit;
614             }
615              
616             # Submit the form to Spamcop OR cancel report
617 1 50       7 unless ($_cancel) { # SUBMIT spam
618              
619 1 50       22 if ( $logger->is_debug ) {
620 0         0 $logger->debug(
621             'Submitting form. We will use the default recipients.');
622             $logger->debug(
623 0         0 'Sleeping for ' . $opts_ref->{delay} . ' seconds.' );
624             }
625              
626 1         1000326 sleep $opts_ref->{delay};
627              
628             # click default button, submit
629 1         50 $response_ref = $ua->complete_report( $form->click() );
630             }
631             else { # CANCEL SPAM
632 0         0 $logger->debug('About to cancel report.');
633 0         0 $response_ref = $ua->complete_report( $form->click('cancel') );
634             }
635              
636 1 50       7606 return 0 unless ($response_ref);
637              
638 1 50       6 if ($_cancel) {
639 0         0 return 1; # user decided this mail is not SPAM
640             }
641              
642             # parse response
643 1         9 my $receivers_ref = find_receivers($response_ref);
644 1         9 $summary->set_receivers($receivers_ref);
645              
646 1 50       3 if ( scalar( @{$receivers_ref} ) > 0 ) {
  1         4  
647              
648 1 50       8 if ( $logger->is_info ) {
649 1         15 $logger->info( 'Spamcop.net sent following SPAM reports: '
650             . $summary->to_text('receivers') );
651 1         196 $logger->info('Finished processing.');
652             }
653              
654             }
655             else {
656 0         0 my $msg = <<'EOM';
657             Spamcop.net returned unexpected content (no SPAM report id, no receiver).
658             Please make check if there new version of App-SpamcupNG available and upgrade it.
659             If you already have the latest version, please open a bug report in the
660             App-SpamcupNG homepage and provide the next lines with the HTML response
661             provided by Spamcop.
662             EOM
663 0         0 $logger->warn($msg);
664 0         0 $logger->warn($response_ref);
665             }
666              
667 1 50       101 $logger->debug( 'SPAM report summary: ' . $summary->as_text )
668             if ( $logger->is_debug );
669              
670 1 50       13 if ( $opts_ref->{database}->{enabled} ) {
671             $logger->info( 'Persisting summary to SQLite database at '
672             . $opts_ref->{database}->{path} )
673 1 50       4 if ( $logger->is_info );
674             my $recorder = App::SpamcupNG::Summary::Recorder->new(
675 1         112 $opts_ref->{database}->{path} );
676 1         8 $recorder->init;
677 1         14777 $recorder->save($summary);
678             }
679              
680 1         156 return 1;
681              
682             # END OF THE LOOP
683             }
684              
685             =head1 SEE ALSO
686              
687             =over
688              
689             =item *
690              
691             L<Log::Log4perl>
692              
693             =back
694              
695             =head1 AUTHOR
696              
697             Alceu Rodrigues de Freitas Junior, E<lt>arfreitas@cpan.orgE<gt>
698              
699             =head1 COPYRIGHT AND LICENSE
700              
701             This software is copyright (c) 2018 of Alceu Rodrigues de Freitas Junior,
702             E<lt>arfreitas@cpan.orgE<gt>
703              
704             This file is part of App-SpamcupNG distribution.
705              
706             App-SpamcupNG is free software: you can redistribute it and/or modify it under
707             the terms of the GNU General Public License as published by the Free Software
708             Foundation, either version 3 of the License, or (at your option) any later
709             version.
710              
711             App-SpamcupNG is distributed in the hope that it will be useful, but WITHOUT
712             ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
713             FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
714              
715             You should have received a copy of the GNU General Public License along with
716             App-SpamcupNG. If not, see <http://www.gnu.org/licenses/>.
717              
718             =cut
719              
720             1;