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