diff options
author | ivan <ivan> | 2009-12-31 13:16:41 +0000 |
---|---|---|
committer | ivan <ivan> | 2009-12-31 13:16:41 +0000 |
commit | b4b0c7e72d7eaee2fbfc7022022c9698323203dd (patch) | |
tree | ba4cd21399e412c32fe3737eaa8478e3271509f9 /rt/lib/RT/Interface | |
parent | 2dfda73eeb3eae2d4f894099754794ef07d060dd (diff) |
import rt 3.8.7
Diffstat (limited to 'rt/lib/RT/Interface')
-rw-r--r-- | rt/lib/RT/Interface/CLI.pm | 24 | ||||
-rwxr-xr-x | rt/lib/RT/Interface/Email.pm | 1205 | ||||
-rwxr-xr-x | rt/lib/RT/Interface/Email/Auth/GnuPG.pm | 211 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Email/Auth/MailFrom.pm | 99 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Email/Filter/SpamAssassin.pm | 8 | ||||
-rw-r--r-- | rt/lib/RT/Interface/REST.pm | 58 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Web.pm | 1849 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Web/Handler.pm | 78 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Web/Menu.pm | 5 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Web/Menu/Item.pm | 5 | ||||
-rwxr-xr-x | rt/lib/RT/Interface/Web/QueryBuilder.pm | 5 | ||||
-rwxr-xr-x | rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm | 180 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Web/Request.pm | 207 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Web/Session.pm | 285 | ||||
-rwxr-xr-x | rt/lib/RT/Interface/Web/Standalone.pm | 49 | ||||
-rw-r--r-- | rt/lib/RT/Interface/Web/Standalone/PreFork.pm | 103 |
16 files changed, 3171 insertions, 1200 deletions
diff --git a/rt/lib/RT/Interface/CLI.pm b/rt/lib/RT/Interface/CLI.pm index 533150415..a910fb48a 100644 --- a/rt/lib/RT/Interface/CLI.pm +++ b/rt/lib/RT/Interface/CLI.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + use strict; use RT; @@ -53,14 +54,12 @@ package RT::Interface::CLI; BEGIN { - use Exporter (); - use vars qw ($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS); + use base 'Exporter'; + use vars qw ($VERSION @EXPORT @EXPORT_OK %EXPORT_TAGS); # set the version for version checking - $VERSION = do { my @r = (q$Revision: 1.1.1.7 $ =~ /\d+/g); sprintf "%d."."%02d" x $#r, @r }; # must be all one line, for MakeMaker - - @ISA = qw(Exporter); - + $VERSION = do { my @r = (q$Revision: 1.1.1.8 $ =~ /\d+/g); sprintf "%d."."%02d" x $#r, @r }; # must be all one line, for MakeMaker + # your exported package globals go here, # as well as any optionally exported functions @EXPORT_OK = qw(&CleanEnv @@ -100,11 +99,6 @@ BEGIN { =head1 METHODS -=begin testing - -ok(require RT::Interface::CLI); - -=end testing =cut @@ -226,7 +220,7 @@ sub GetMessageContent { if ($edit) { unless ($ENV{'EDITOR'}) { - $RT::Logger->crit('No $EDITOR variable defined'. "\n"); + $RT::Logger->crit('No $EDITOR variable defined'); return undef; } system ($ENV{'EDITOR'}, $filename); @@ -249,7 +243,7 @@ sub debug { my $val = shift; my ($debug); if ($val) { - $RT::Logger->debug($val."\n"); + $RT::Logger->debug($val); if ($debug) { print STDERR "$val\n"; } diff --git a/rt/lib/RT/Interface/Email.pm b/rt/lib/RT/Interface/Email.pm index 14ae2a058..b669b5b2f 100755 --- a/rt/lib/RT/Interface/Email.pm +++ b/rt/lib/RT/Interface/Email.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,24 +45,26 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Email; use strict; -use Mail::Address; +use warnings; + +use Email::Address; use MIME::Entity; use RT::EmailParser; use File::Temp; use UNIVERSAL::require; +use Mail::Mailer (); BEGIN { - use Exporter (); - use vars qw ( @ISA @EXPORT_OK); + use base 'Exporter'; + use vars qw ( @EXPORT_OK); # set the version for version checking our $VERSION = 2.0; - @ISA = qw(Exporter); - # your exported package globals go here, # as well as any optionally exported functions @EXPORT_OK = qw( @@ -95,37 +97,41 @@ BEGIN { =head1 DESCRIPTION -=begin testing -ok(require RT::Interface::Email); -=end testing +=head1 METHODS +=head2 CheckForLoops HEAD -=head1 METHODS +Takes a HEAD object of L<MIME::Head> class and returns true if the +message's been sent by this RT instance. Uses "X-RT-Loop-Prevention" +field of the head for test. =cut -# {{{ sub CheckForLoops - sub CheckForLoops { my $head = shift; - #If this instance of RT sent it our, we don't want to take it in + # If this instance of RT sent it our, we don't want to take it in my $RTLoop = $head->get("X-RT-Loop-Prevention") || ""; - chomp($RTLoop); #remove that newline - if ( $RTLoop eq "$RT::rtname" ) { - return (1); + chomp ($RTLoop); # remove that newline + if ( $RTLoop eq RT->Config->Get('rtname') ) { + return 1; } # TODO: We might not trap the case where RT instance A sends a mail # to RT instance B which sends a mail to ... - return (undef); + return undef; } -# }}} +=head2 CheckForSuspiciousSender HEAD + +Takes a HEAD object of L<MIME::Head> class and returns true if sender +is suspicious. Suspicious means mailer daemon. + +See also L</ParseSenderAddressFromHead>. -# {{{ sub CheckForSuspiciousSender +=cut sub CheckForSuspiciousSender { my $head = shift; @@ -143,19 +149,24 @@ sub CheckForSuspiciousSender { my ( $From, $junk ) = ParseSenderAddressFromHead($head); if ( ( $From =~ /^mailer-daemon\@/i ) - or ( $From =~ /^postmaster\@/i ) ) + or ( $From =~ /^postmaster\@/i ) + or ( $From eq "" )) { return (1); } - return (undef); - + return undef; } -# }}} +=head2 CheckForAutoGenerated HEAD + +Takes a HEAD object of L<MIME::Head> class and returns true if message +is autogenerated. Checks 'Precedence' and 'X-FC-Machinegenerated' +fields of the head in tests. + +=cut -# {{{ sub CheckForAutoGenerated sub CheckForAutoGenerated { my $head = shift; @@ -164,6 +175,13 @@ sub CheckForAutoGenerated { return (1); } + # Per RFC3834, any Auto-Submitted header which is not "no" means + # it is auto-generated. + my $AutoSubmitted = $head->get("Auto-Submitted") || ""; + if ( length $AutoSubmitted and $AutoSubmitted ne "no" ) { + return (1); + } + # First Class mailer uses this as a clue. my $FCJunk = $head->get("X-FC-Machinegenerated") || ""; if ( $FCJunk =~ /^true/i ) { @@ -173,9 +191,7 @@ sub CheckForAutoGenerated { return (0); } -# }}} -# {{{ sub CheckForBounce sub CheckForBounce { my $head = shift; @@ -183,55 +199,40 @@ sub CheckForBounce { return ( $ReturnPath =~ /<>/ ); } -# }}} -# {{{ IsRTAddress +=head2 MailError PARAM HASH -=head2 IsRTAddress ADDRESS +Sends an error message. Takes a param hash: -Takes a single parameter, an email address. -Returns true if that address matches the $RTAddressRegexp. -Returns false, otherwise. +=over 4 -=cut +=item From - sender's address, by default is 'CorrespondAddress'; -sub IsRTAddress { - my $address = shift || ''; +=item To - recipient, by default is 'OwnerEmail'; - # Example: the following rule would tell RT not to Cc - # "tickets@noc.example.com" - if ( defined($RT::RTAddressRegexp) - && $address =~ /$RT::RTAddressRegexp/i ) - { - return (1); - } else { - return (undef); - } -} +=item Bcc - optional Bcc recipients; -# }}} +=item Subject - subject of the message, default is 'There has been an error'; -# {{{ CullRTAddresses +=item Explanation - main content of the error, default value is 'Unexplained error'; -=head2 CullRTAddresses ARRAY +=item MIMEObj - optional MIME entity that's attached to the error mail, as well we +add 'In-Reply-To' field to the error that points to this message. -Takes a single argument, an array of email addresses. -Returns the same array with any IsRTAddress()es weeded out. +=item Attach - optional text that attached to the error as 'message/rfc822' part. -=cut +=item LogLevel - log level under which we should write explanation message into the +log, by default we log it as critical. -sub CullRTAddresses { - return grep !IsRTAddress($_), @_; -} +=back -# }}} +=cut -# {{{ sub MailError sub MailError { my %args = ( - To => $RT::OwnerEmail, + To => RT->Config->Get('OwnerEmail'), Bcc => undef, - From => $RT::CorrespondAddress, + From => RT->Config->Get('CorrespondAddress'), Subject => 'There has been an error', Explanation => 'Unexplained error', MIMEObj => undef, @@ -243,7 +244,7 @@ sub MailError { $RT::Logger->log( level => $args{'LogLevel'}, message => $args{'Explanation'} - ); + ) if $args{'LogLevel'}; # the colons are necessary to make ->build include non-standard headers my $entity = MIME::Entity->build( Type => "multipart/mixed", @@ -252,16 +253,15 @@ sub MailError { To => $args{'To'}, Subject => $args{'Subject'}, 'Precedence:' => 'bulk', - 'X-RT-Loop-Prevention:' => $RT::rtname, - 'In-Reply-To:' => $args{'MIMEObj'} ? $args{'MIMEObj'}->head->get('Message-Id') : undef + 'X-RT-Loop-Prevention:' => RT->Config->Get('rtname'), ); + SetInReplyTo( Message => $entity, InReplyTo => $args{'MIMEObj'} ); $entity->attach( Data => $args{'Explanation'} . "\n" ); - my $mimeobj = $args{'MIMEObj'}; - if ($mimeobj) { - $mimeobj->sync_headers(); - $entity->add_part($mimeobj); + if ( $args{'MIMEObj'} ) { + $args{'MIMEObj'}->sync_headers; + $entity->add_part( $args{'MIMEObj'} ); } if ( $args{'Attach'} ) { @@ -269,25 +269,628 @@ sub MailError { } - if ( $RT::MailCommand eq 'sendmailpipe' ) { - open( MAIL, - "|$RT::SendmailPath $RT::SendmailBounceArguments $RT::SendmailArguments" - ) - || return (0); - print MAIL $entity->as_string; - close(MAIL); + SendEmail( Entity => $entity, Bounce => 1 ); +} + + +=head2 SendEmail Entity => undef, [ Bounce => 0, Ticket => undef, Transaction => undef ] + +Sends an email (passed as a L<MIME::Entity> object C<ENTITY>) using +RT's outgoing mail configuration. If C<BOUNCE> is passed, and is a +true value, the message will be marked as an autogenerated error, if +possible. Sets Date field of the head to now if it's not set. + +Ticket and Transaction arguments are optional. If Transaction is +specified and Ticket is not then ticket of the transaction is +used, but only if the transaction belongs to a ticket. + +Returns 1 on success, 0 on error or -1 if message has no recipients +and hasn't been sent. + +=head3 Signing and Encrypting + +This function as well signs and/or encrypts the message according to +headers of a transaction's attachment or properties of a ticket's queue. +To get full access to the configuration Ticket and/or Transaction +arguments must be provided, but you can force behaviour using Sign +and/or Encrypt arguments. + +The following precedence of arguments are used to figure out if +the message should be encrypted and/or signed: + +* if Sign or Encrypt argument is defined then its value is used + +* else if Transaction's first attachment has X-RT-Sign or X-RT-Encrypt +header field then it's value is used + +* else properties of a queue of the Ticket are used. + +=cut + +sub SendEmail { + my (%args) = ( + Entity => undef, + Bounce => 0, + Ticket => undef, + Transaction => undef, + @_, + ); + + my $TicketObj = $args{'Ticket'}; + my $TransactionObj = $args{'Transaction'}; + + foreach my $arg( qw(Entity Bounce) ) { + next unless defined $args{ lc $arg }; + + $RT::Logger->warning("'". lc($arg) ."' argument is deprecated, use '$arg' instead"); + $args{ $arg } = delete $args{ lc $arg }; + } + + unless ( $args{'Entity'} ) { + $RT::Logger->crit( "Could not send mail without 'Entity' object" ); + return 0; + } + + my $msgid = $args{'Entity'}->head->get('Message-ID') || ''; + chomp $msgid; + + # If we don't have any recipients to send to, don't send a message; + unless ( $args{'Entity'}->head->get('To') + || $args{'Entity'}->head->get('Cc') + || $args{'Entity'}->head->get('Bcc') ) + { + $RT::Logger->info( $msgid . " No recipients found. Not sending." ); + return -1; + } + + if ( $TransactionObj && !$TicketObj + && $TransactionObj->ObjectType eq 'RT::Ticket' ) + { + $TicketObj = $TransactionObj->Object; + } + + if ( RT->Config->Get('GnuPG')->{'Enable'} ) { + my %crypt; + + my $attachment; + $attachment = $TransactionObj->Attachments->First + if $TransactionObj; + + foreach my $argument ( qw(Sign Encrypt) ) { + next if defined $args{ $argument }; + + if ( $attachment && defined $attachment->GetHeader("X-RT-$argument") ) { + $crypt{$argument} = $attachment->GetHeader("X-RT-$argument"); + } elsif ( $TicketObj ) { + $crypt{$argument} = $TicketObj->QueueObj->$argument(); + } + } + + my $res = SignEncrypt( %args, %crypt ); + return $res unless $res > 0; + } + + unless ( $args{'Entity'}->head->get('Date') ) { + require RT::Date; + my $date = RT::Date->new( $RT::SystemUser ); + $date->SetToNow; + $args{'Entity'}->head->set( 'Date', $date->RFC2822( Timezone => 'server' ) ); + } + + my $mail_command = RT->Config->Get('MailCommand'); + + if ($mail_command eq 'testfile') { + $Mail::Mailer::testfile::config{outfile} = File::Temp->new; + } + + # if it is a sub routine, we just return it; + return $mail_command->($args{'Entity'}) if UNIVERSAL::isa( $mail_command, 'CODE' ); + + if ( $mail_command eq 'sendmailpipe' ) { + my $path = RT->Config->Get('SendmailPath'); + my $args = RT->Config->Get('SendmailArguments'); + + # SetOutgoingMailFrom + if ( RT->Config->Get('SetOutgoingMailFrom') ) { + my $OutgoingMailAddress; + + if ($TicketObj) { + my $QueueName = $TicketObj->QueueObj->Name; + my $QueueAddressOverride = RT->Config->Get('OverrideOutgoingMailFrom')->{$QueueName}; + + if ($QueueAddressOverride) { + $OutgoingMailAddress = $QueueAddressOverride; + } else { + $OutgoingMailAddress = $TicketObj->QueueObj->CorrespondAddress; + } + } + + $OutgoingMailAddress ||= RT->Config->Get('OverrideOutgoingMailFrom')->{'Default'}; + + $args .= " -f $OutgoingMailAddress" + if $OutgoingMailAddress; + } + + # Set Bounce Arguments + $args .= ' '. RT->Config->Get('SendmailBounceArguments') if $args{'Bounce'}; + + # VERP + if ( $TransactionObj and + my $prefix = RT->Config->Get('VERPPrefix') and + my $domain = RT->Config->Get('VERPDomain') ) + { + my $from = $TransactionObj->CreatorObj->EmailAddress; + $from =~ s/@/=/g; + $from =~ s/\s//g; + $args .= " -f $prefix$from\@$domain"; + } + + eval { + # don't ignore CHLD signal to get proper exit code + local $SIG{'CHLD'} = 'DEFAULT'; + + open my $mail, "|$path $args" or die "couldn't execute program: $!"; + + # if something wrong with $mail->print we will get PIPE signal, handle it + local $SIG{'PIPE'} = sub { die "program unexpectedly closed pipe" }; + $args{'Entity'}->print($mail); + + unless ( close $mail ) { + die "close pipe failed: $!" if $!; # system error + # sendmail exit statuses mostly errors with data not software + # TODO: status parsing: core dump, exit on signal or EX_* + my $msg = "$msgid: `$path $args` exitted with code ". ($?>>8); + $msg = ", interrupted by signal ". ($?&127) if $?&127; + $RT::Logger->error( $msg ); + } + }; + if ( $@ ) { + $RT::Logger->crit( "$msgid: Could not send mail with command `$path $args`: " . $@ ); + return 0; + } + } + elsif ( $mail_command eq 'smtp' ) { + require Net::SMTP; + my $smtp = do { local $@; eval { Net::SMTP->new( + Host => RT->Config->Get('SMTPServer'), + Debug => RT->Config->Get('SMTPDebug'), + ) } }; + unless ( $smtp ) { + $RT::Logger->crit( "Could not connect to SMTP server."); + return 0; + } + + # duplicate head as we want drop Bcc field + my $head = $args{'Entity'}->head->dup; + my @recipients = map $_->address, map + Email::Address->parse($head->get($_)), qw(To Cc Bcc); + $head->delete('Bcc'); + + my $sender = RT->Config->Get('SMTPFrom') + || $args{'Entity'}->head->get('From'); + chomp $sender; + + my $status = $smtp->mail( $sender ) + && $smtp->recipient( @recipients ); + + if ( $status ) { + $smtp->data; + my $fh = $smtp->tied_fh; + $head->print( $fh ); + print $fh "\n"; + $args{'Entity'}->print_body( $fh ); + $smtp->dataend; + } + $smtp->quit; + + unless ( $status ) { + $RT::Logger->crit( "$msgid: Could not send mail via SMTP." ); + return 0; + } + } + else { + local ($ENV{'MAILADDRESS'}, $ENV{'PERL_MAILERS'}); + + my @mailer_args = ($mail_command); + if ( $mail_command eq 'sendmail' ) { + $ENV{'PERL_MAILERS'} = RT->Config->Get('SendmailPath'); + push @mailer_args, split(/\s+/, RT->Config->Get('SendmailArguments')); + } + else { + push @mailer_args, RT->Config->Get('MailParams'); + } + + unless ( $args{'Entity'}->send( @mailer_args ) ) { + $RT::Logger->crit( "$msgid: Could not send mail." ); + return 0; + } + } + return 1; +} + +=head2 PrepareEmailUsingTemplate Template => '', Arguments => {} + +Loads a template. Parses it using arguments if it's not empty. +Returns a tuple (L<RT::Template> object, error message). + +Note that even if a template object is returned MIMEObj method +may return undef for empty templates. + +=cut + +sub PrepareEmailUsingTemplate { + my %args = ( + Template => '', + Arguments => {}, + @_ + ); + + my $template = RT::Template->new( $RT::SystemUser ); + $template->LoadGlobalTemplate( $args{'Template'} ); + unless ( $template->id ) { + return (undef, "Couldn't load template '". $args{'Template'} ."'"); + } + return $template if $template->IsEmpty; + + my ($status, $msg) = $template->Parse( %{ $args{'Arguments'} } ); + return (undef, $msg) unless $status; + + return $template; +} + +=head2 SendEmailUsingTemplate Template => '', Arguments => {}, From => CorrespondAddress, To => '', Cc => '', Bcc => '' + +Sends email using a template, takes name of template, arguments for it and recipients. + +=cut + +sub SendEmailUsingTemplate { + my %args = ( + Template => '', + Arguments => {}, + To => undef, + Cc => undef, + Bcc => undef, + From => RT->Config->Get('CorrespondAddress'), + InReplyTo => undef, + @_ + ); + + my ($template, $msg) = PrepareEmailUsingTemplate( %args ); + return (0, $msg) unless $template; + + my $mail = $template->MIMEObj; + unless ( $mail ) { + $RT::Logger->info("Message is not sent as template #". $template->id ." is empty"); + return -1; + } + + $mail->head->set( $_ => $args{ $_ } ) + foreach grep defined $args{$_}, qw(To Cc Bcc From); + + SetInReplyTo( Message => $mail, InReplyTo => $args{'InReplyTo'} ); + + return SendEmail( Entity => $mail ); +} + +=head2 ForwardTransaction TRANSACTION, To => '', Cc => '', Bcc => '' + +Forwards transaction with all attachments as 'message/rfc822'. + +=cut + +sub ForwardTransaction { + my $txn = shift; + my %args = ( To => '', Cc => '', Bcc => '', @_ ); + + my $entity = $txn->ContentAsMIME; + + return SendForward( %args, Entity => $entity, Transaction => $txn ); +} + +=head2 ForwardTicket TICKET, To => '', Cc => '', Bcc => '' + +Forwards a ticket's Create and Correspond Transactions and their Attachments as 'message/rfc822'. + +=cut + +sub ForwardTicket { + my $ticket = shift; + my %args = ( To => '', Cc => '', Bcc => '', @_ ); + + my $txns = $ticket->Transactions; + $txns->Limit( + FIELD => 'Type', + VALUE => $_, + ) for qw(Create Correspond); + + my $entity = MIME::Entity->build( + Type => 'multipart/mixed', + ); + $entity->add_part( $_ ) foreach + map $_->ContentAsMIME, + @{ $txns->ItemsArrayRef }; + + return SendForward( %args, Entity => $entity, Ticket => $ticket, Template => 'Forward Ticket' ); +} + +=head2 SendForward Entity => undef, Ticket => undef, Transaction => undef, Template => undef, To => '', Cc => '', Bcc => '' + +Forwards an Entity representing Ticket or Transaction as 'message/rfc822'. Entity is wrapped into Template. + +=cut + +sub SendForward { + my (%args) = ( + Entity => undef, + Ticket => undef, + Transaction => undef, + Template => 'Forward', + To => '', Cc => '', Bcc => '', + @_ + ); + + my $txn = $args{'Transaction'}; + my $ticket = $args{'Ticket'}; + $ticket ||= $txn->Object if $txn; + + my $entity = $args{'Entity'}; + unless ( $entity ) { + require Carp; + $RT::Logger->error(Carp::longmess("No entity provided")); + return (0, $ticket->loc("Couldn't send email")); + } + + my ($template, $msg) = PrepareEmailUsingTemplate( + Template => $args{'Template'}, + Arguments => { + Ticket => $ticket, + Transaction => $txn, + }, + ); + + my $mail; + if ( $template ) { + $mail = $template->MIMEObj; } else { - $entity->send( $RT::MailCommand, $RT::MailParams ); + $RT::Logger->warning($msg); + } + unless ( $mail ) { + $RT::Logger->warning("Couldn't generate email using template '$args{Template}'"); + + my $description; + unless ( $args{'Transaction'} ) { + $description = 'This is forward of ticket #'. $ticket->id; + } else { + $description = 'This is forward of transaction #' + . $txn->id ." of a ticket #". $txn->ObjectId; + } + $mail = MIME::Entity->build( + Type => 'text/plain', + Data => $description, + ); + } + + $mail->head->set( $_ => EncodeToMIME( String => $args{$_} ) ) + foreach grep defined $args{$_}, qw(To Cc Bcc); + + $mail->attach( + Type => 'message/rfc822', + Disposition => 'attachment', + Description => 'forwarded message', + Data => $entity->as_string, + ); + + my $from; + my $subject = ''; + $subject = $txn->Subject if $txn; + $subject ||= $ticket->Subject if $ticket; + if ( RT->Config->Get('ForwardFromUser') ) { + $from = ($txn || $ticket)->CurrentUser->UserObj->EmailAddress; + } else { + # XXX: what if want to forward txn of other object than ticket? + $subject = AddSubjectTag( $subject, $ticket ); + $from = $ticket->QueueObj->CorrespondAddress + || RT->Config->Get('CorrespondAddress'); + } + $mail->head->set( Subject => EncodeToMIME( String => "Fwd: $subject" ) ); + $mail->head->set( From => EncodeToMIME( String => $from ) ); + + my $status = RT->Config->Get('ForwardFromUser') + # never sign if we forward from User + ? SendEmail( %args, Entity => $mail, Sign => 0 ) + : SendEmail( %args, Entity => $mail ); + return (0, $ticket->loc("Couldn't send email")) unless $status; + return (1, $ticket->loc("Send email successfully")); +} + +=head2 SignEncrypt Entity => undef, Sign => 0, Encrypt => 0 + +Signs and encrypts message using L<RT::Crypt::GnuPG>, but as well +handle errors with users' keys. + +If a recipient has no key or has other problems with it, then the +unction sends a error to him using 'Error: public key' template. +Also, notifies RT's owner using template 'Error to RT owner: public key' +to inform that there are problems with users' keys. Then we filter +all bad recipients and retry. + +Returns 1 on success, 0 on error and -1 if all recipients are bad and +had been filtered out. + +=cut + +sub SignEncrypt { + my %args = ( + Entity => undef, + Sign => 0, + Encrypt => 0, + @_ + ); + return 1 unless $args{'Sign'} || $args{'Encrypt'}; + + my $msgid = $args{'Entity'}->head->get('Message-ID') || ''; + chomp $msgid; + + $RT::Logger->debug("$msgid Signing message") if $args{'Sign'}; + $RT::Logger->debug("$msgid Encrypting message") if $args{'Encrypt'}; + + require RT::Crypt::GnuPG; + my %res = RT::Crypt::GnuPG::SignEncrypt( %args ); + return 1 unless $res{'exit_code'}; + + my @status = RT::Crypt::GnuPG::ParseStatus( $res{'status'} ); + + my @bad_recipients; + foreach my $line ( @status ) { + # if the passphrase fails, either you have a bad passphrase + # or gpg-agent has died. That should get caught in Create and + # Update, but at least throw an error here + if (($line->{'Operation'}||'') eq 'PassphraseCheck' + && $line->{'Status'} =~ /^(?:BAD|MISSING)$/ ) { + $RT::Logger->error( "$line->{'Status'} PASSPHRASE: $line->{'Message'}" ); + return 0; + } + next unless ($line->{'Operation'}||'') eq 'RecipientsCheck'; + next if $line->{'Status'} eq 'DONE'; + $RT::Logger->error( $line->{'Message'} ); + push @bad_recipients, $line; + } + return 0 unless @bad_recipients; + + $_->{'AddressObj'} = (Email::Address->parse( $_->{'Recipient'} ))[0] + foreach @bad_recipients; + + foreach my $recipient ( @bad_recipients ) { + my $status = SendEmailUsingTemplate( + To => $recipient->{'AddressObj'}->address, + Template => 'Error: public key', + Arguments => { + %$recipient, + TicketObj => $args{'Ticket'}, + TransactionObj => $args{'Transaction'}, + }, + ); + unless ( $status ) { + $RT::Logger->error("Couldn't send 'Error: public key'"); + } + } + + my $status = SendEmailUsingTemplate( + To => RT->Config->Get('OwnerEmail'), + Template => 'Error to RT owner: public key', + Arguments => { + BadRecipients => \@bad_recipients, + TicketObj => $args{'Ticket'}, + TransactionObj => $args{'Transaction'}, + }, + ); + unless ( $status ) { + $RT::Logger->error("Couldn't send 'Error to RT owner: public key'"); + } + + DeleteRecipientsFromHead( + $args{'Entity'}->head, + map $_->{'AddressObj'}->address, @bad_recipients + ); + + unless ( $args{'Entity'}->head->get('To') + || $args{'Entity'}->head->get('Cc') + || $args{'Entity'}->head->get('Bcc') ) + { + $RT::Logger->debug("$msgid No recipients that have public key, not sending"); + return -1; } + + # redo without broken recipients + %res = RT::Crypt::GnuPG::SignEncrypt( %args ); + return 0 if $res{'exit_code'}; + + return 1; } -# }}} +use MIME::Words (); + +=head2 EncodeToMIME + +Takes a hash with a String and a Charset. Returns the string encoded +according to RFC2047, using B (base64 based) encoding. + +String must be a perl string, octets are returned. + +If Charset is not provided then $EmailOutputEncoding config option +is used, or "latin-1" if that is not set. + +=cut + +sub EncodeToMIME { + my %args = ( + String => undef, + Charset => undef, + @_ + ); + my $value = $args{'String'}; + return $value unless $value; # 0 is perfect ascii + my $charset = $args{'Charset'} || RT->Config->Get('EmailOutputEncoding'); + my $encoding = 'B'; + + # using RFC2047 notation, sec 2. + # encoded-word = "=?" charset "?" encoding "?" encoded-text "?=" + + # An 'encoded-word' may not be more than 75 characters long + # + # MIME encoding increases 4/3*(number of bytes), and always in multiples + # of 4. Thus we have to find the best available value of bytes available + # for each chunk. + # + # First we get the integer max which max*4/3 would fit on space. + # Then we find the greater multiple of 3 lower or equal than $max. + my $max = int( + ( ( 75 - length( '=?' . $charset . '?' . $encoding . '?' . '?=' ) ) + * 3 + ) / 4 + ); + $max = int( $max / 3 ) * 3; + + chomp $value; + + if ( $max <= 0 ) { + + # gives an error... + $RT::Logger->crit("Can't encode! Charset or encoding too big."); + return ($value); + } + + return ($value) unless $value =~ /[^\x20-\x7e]/; + + $value =~ s/\s+$//; -# {{{ Create User + # we need perl string to split thing char by char + Encode::_utf8_on($value) unless Encode::is_utf8($value); + + my ( $tmp, @chunks ) = ( '', () ); + while ( length $value ) { + my $char = substr( $value, 0, 1, '' ); + my $octets = Encode::encode( $charset, $char ); + if ( length($tmp) + length($octets) > $max ) { + push @chunks, $tmp; + $tmp = ''; + } + $tmp .= $octets; + } + push @chunks, $tmp if length $tmp; + + # encode an join chuncks + $value = join "\n ", + map MIME::Words::encode_mimeword( $_, $encoding, $charset ), + @chunks; + return ($value); +} sub CreateUser { my ( $Username, $Address, $Name, $ErrorsTo, $entity ) = @_; - my $NewUser = RT::User->new($RT::SystemUser); + + my $NewUser = RT::User->new( $RT::SystemUser ); my ( $Val, $Message ) = $NewUser->Create( Name => ( $Username || $Address ), @@ -295,7 +898,7 @@ sub CreateUser { RealName => $Name, Password => undef, Privileged => 0, - Comments => 'Autocreated on ticket submission' + Comments => 'Autocreated on ticket submission', ); unless ($Val) { @@ -316,14 +919,14 @@ sub CreateUser { Explanation => "User creation failed in mailgateway: $Message", MIMEObj => $entity, - LogLevel => 'crit' + LogLevel => 'crit', ); } } #Load the new user object - my $CurrentUser = RT::CurrentUser->new(); - $CurrentUser->LoadByEmail($Address); + my $CurrentUser = new RT::CurrentUser; + $CurrentUser->LoadByEmail( $Address ); unless ( $CurrentUser->id ) { $RT::Logger->warning( @@ -341,15 +944,13 @@ sub CreateUser { return $CurrentUser; } -# }}} -# {{{ ParseCcAddressesFromHead -=head2 ParseCcAddressesFromHead HASHREF +=head2 ParseCcAddressesFromHead HASH -Takes a hashref object containing QueueObj, Head and CurrentUser objects. -Returns a list of all email addresses in the To and Cc -headers b<except> the current Queue\'s email addresses, the CurrentUser\'s +Takes a hash containing QueueObj, Head and CurrentUser objects. +Returns a list of all email addresses in the To and Cc +headers b<except> the current Queue\'s email addresses, the CurrentUser\'s email address and anything that the configuration sub RT::IsRTAddress matches. =cut @@ -362,32 +963,29 @@ sub ParseCcAddressesFromHead { @_ ); - my (@Addresses); + my @recipients = + map lc $_->address, + map Email::Address->parse( $args{'Head'}->get( $_ ) ), + qw(To Cc); - my @ToObjs = Mail::Address->parse( $args{'Head'}->get('To') ); - my @CcObjs = Mail::Address->parse( $args{'Head'}->get('Cc') ); + my @res; + foreach my $address ( @recipients ) { + $address = $args{'CurrentUser'}->UserObj->CanonicalizeEmailAddress( $address ); + next if lc $args{'CurrentUser'}->EmailAddress eq $address; + next if lc $args{'QueueObj'}->CorrespondAddress eq $address; + next if lc $args{'QueueObj'}->CommentAddress eq $address; + next if RT::EmailParser->IsRTAddress( $address ); - foreach my $AddrObj ( @ToObjs, @CcObjs ) { - my $Address = $AddrObj->address; - $Address = $args{'CurrentUser'} - ->UserObj->CanonicalizeEmailAddress($Address); - next if ( $args{'CurrentUser'}->EmailAddress =~ /^\Q$Address\E$/i ); - next if ( $args{'QueueObj'}->CorrespondAddress =~ /^\Q$Address\E$/i ); - next if ( $args{'QueueObj'}->CommentAddress =~ /^\Q$Address\E$/i ); - next if ( RT::EmailParser->IsRTAddress($Address) ); - - push( @Addresses, $Address ); + push @res, $address; } - return (@Addresses); + return @res; } -# }}} -# {{{ ParseSenderAdddressFromHead -=head2 ParseSenderAddressFromHead +=head2 ParseSenderAddressFromHead HEAD -Takes a MIME::Header object. Returns a tuple: (user@host, friendly name) +Takes a MIME::Header object. Returns a tuple: (user@host, friendly name) of the From (evaluated in order of Reply-To:, From:, Sender) =cut @@ -397,19 +995,16 @@ sub ParseSenderAddressFromHead { #Figure out who's sending this message. foreach my $header ('Reply-To', 'From', 'Sender') { - my $From = $head->get($header); - my ($addr, $name) = ParseAddressFromHeader($From); + my $addr_line = $head->get($header) || next; + my ($addr, $name) = ParseAddressFromHeader( $addr_line ); # only return if the address is not empty return ($addr, $name) if $addr; } return (undef, undef); } -# }}} - -# {{{ ParseErrorsToAdddressFromHead -=head2 ParseErrorsToAddressFromHead +=head2 ParseErrorsToAddressFromHead HEAD Takes a MIME::Header object. Return a single value : user@host of the From (evaluated in order of Return-path:,Errors-To:,Reply-To:, @@ -435,13 +1030,11 @@ sub ParseErrorsToAddressFromHead { } } -# }}} -# {{{ ParseAddressFromHeader =head2 ParseAddressFromHeader ADDRESS -Takes an address from $head->get('Line') and returns a tuple: user@host, friendly name +Takes an address from C<$head->get('Line')> and returns a tuple: user@host, friendly name =cut @@ -449,15 +1042,15 @@ sub ParseAddressFromHeader { my $Addr = shift; # Some broken mailers send: ""Vincent, Jesse"" <jesse@fsck.com>. Hate - $Addr =~ s/\"\"(.*?)\"\"/\"$1\"/g; - my @Addresses = Mail::Address->parse($Addr); + $Addr =~ s/\"\"(.*?)\"\"/\"$1\"/g; + my @Addresses = RT::EmailParser->ParseEmailAddress($Addr); my ($AddrObj) = grep ref $_, @Addresses; unless ( $AddrObj ) { return ( undef, undef ); } - my $Name = ( $AddrObj->phrase || $AddrObj->comment || $AddrObj->address ); + my $Name = ( $AddrObj->name || $AddrObj->phrase || $AddrObj->comment || $AddrObj->address ); #Lets take the from and load a user object. my $Address = $AddrObj->address; @@ -465,26 +1058,127 @@ sub ParseAddressFromHeader { return ( $Address, $Name ); } -# }}} +=head2 DeleteRecipientsFromHead HEAD RECIPIENTS -# {{{ sub ParseTicketId +Gets a head object and list of addresses. +Deletes addresses from To, Cc or Bcc fields. + +=cut + +sub DeleteRecipientsFromHead { + my $head = shift; + my %skip = map { lc $_ => 1 } @_; + + foreach my $field ( qw(To Cc Bcc) ) { + $head->set( $field => + join ', ', map $_->format, grep !$skip{ lc $_->address }, + Email::Address->parse( $head->get( $field ) ) + ); + } +} + +sub GenMessageId { + my %args = ( + Ticket => undef, + Scrip => undef, + ScripAction => undef, + @_ + ); + my $org = RT->Config->Get('Organization'); + my $ticket_id = ( ref $args{'Ticket'}? $args{'Ticket'}->id : $args{'Ticket'} ) || 0; + my $scrip_id = ( ref $args{'Scrip'}? $args{'Scrip'}->id : $args{'Scrip'} ) || 0; + my $sent = ( ref $args{'ScripAction'}? $args{'ScripAction'}->{'_Message_ID'} : 0 ) || 0; + + return "<rt-". $RT::VERSION ."-". $$ ."-". CORE::time() ."-". int(rand(2000)) .'.' + . $ticket_id ."-". $scrip_id ."-". $sent ."@". $org .">" ; +} + +sub SetInReplyTo { + my %args = ( + Message => undef, + InReplyTo => undef, + Ticket => undef, + @_ + ); + return unless $args{'Message'} && $args{'InReplyTo'}; + + my $get_header = sub { + my @res; + if ( $args{'InReplyTo'}->isa('MIME::Entity') ) { + @res = $args{'InReplyTo'}->head->get( shift ); + } else { + @res = $args{'InReplyTo'}->GetHeader( shift ) || ''; + } + return grep length, map { split /\s+/m, $_ } grep defined, @res; + }; + + my @id = $get_header->('Message-ID'); + #XXX: custom header should begin with X- otherwise is violation of the standard + my @rtid = $get_header->('RT-Message-ID'); + my @references = $get_header->('References'); + unless ( @references ) { + @references = $get_header->('In-Reply-To'); + } + push @references, @id, @rtid; + if ( $args{'Ticket'} ) { + my $pseudo_ref = '<RT-Ticket-'. $args{'Ticket'}->id .'@'. RT->Config->Get('Organization') .'>'; + push @references, $pseudo_ref unless grep $_ eq $pseudo_ref, @references; + } + @references = splice @references, 4, -6 + if @references > 10; + + my $mail = $args{'Message'}; + $mail->head->set( 'In-Reply-To' => join ' ', @rtid? (@rtid) : (@id) ) if @id || @rtid; + $mail->head->set( 'References' => join ' ', @references ); +} sub ParseTicketId { my $Subject = shift; - my $id; - my $test_name = $RT::EmailSubjectTagRegex || qr/\Q$RT::rtname\E/i; + my $rtname = RT->Config->Get('rtname'); + my $test_name = RT->Config->Get('EmailSubjectTagRegex') || qr/\Q$rtname\E/i; + my $id; if ( $Subject =~ s/\[$test_name\s+\#(\d+)\s*\]//i ) { - my $id = $1; - $RT::Logger->debug("Found a ticket ID. It's $id"); - return ($id); + $id = $1; } else { - return (undef); + foreach my $tag ( RT->System->SubjectTag ) { + next unless $Subject =~ s/\[\Q$tag\E\s+\#(\d+)\s*\]//i; + $id = $1; + last; + } } + return undef unless $id; + + $RT::Logger->debug("Found a ticket ID. It's $id"); + return $id; +} + +sub AddSubjectTag { + my $subject = shift; + my $ticket = shift; + unless ( ref $ticket ) { + my $tmp = RT::Ticket->new( $RT::SystemUser ); + $tmp->Load( $ticket ); + $ticket = $tmp; + } + my $id = $ticket->id; + my $queue_tag = $ticket->QueueObj->SubjectTag; + + my $tag_re = RT->Config->Get('EmailSubjectTagRegex'); + unless ( $tag_re ) { + my $tag = $queue_tag || RT->Config->Get('rtname'); + $tag_re = qr/\Q$tag\E/; + } elsif ( $queue_tag ) { + $tag_re = qr/$tag_re|\Q$queue_tag\E/; + } + return $subject if $subject =~ /\[$tag_re\s+#$id\]/; + + $subject =~ s/(\r\n|\n|\s)/ /gi; + chomp $subject; + return "[". ($queue_tag || RT->Config->Get('rtname')) ." #$id] $subject"; } -# }}} =head2 Gateway ARGSREF @@ -506,22 +1200,49 @@ any ticket id found in the subject. Returns: An array of: - + (status code, message, optional ticket object) status code is a numeric value. for temporary failures, the status code should be -75 - for permanent failures which are handled by RT, the status code + for permanent failures which are handled by RT, the status code should be 0 - + for succces, the status code should be 1 =cut +sub _LoadPlugins { + my @mail_plugins = @_; + + my @res; + foreach my $plugin (@mail_plugins) { + if ( ref($plugin) eq "CODE" ) { + push @res, $plugin; + } elsif ( !ref $plugin ) { + my $Class = $plugin; + $Class = "RT::Interface::Email::" . $Class + unless $Class =~ /^RT::Interface::Email::/; + $Class->require or + do { $RT::Logger->error("Couldn't load $Class: $@"); next }; + + no strict 'refs'; + unless ( defined *{ $Class . "::GetCurrentUser" }{CODE} ) { + $RT::Logger->crit( "No GetCurrentUser code found in $Class module"); + next; + } + push @res, $Class; + } else { + $RT::Logger->crit( "$plugin - is not class name or code reference"); + } + } + return @res; +} + sub Gateway { my $argsref = shift; my %args = ( @@ -549,12 +1270,15 @@ sub Gateway { } my $parser = RT::EmailParser->new(); - $parser->SmartParseMIMEEntityFromScalar( Message => $args{'message'} ); - my $Message = $parser->Entity(); + $parser->SmartParseMIMEEntityFromScalar( + Message => $args{'message'}, + Decode => 0, + Exact => 1, + ); + my $Message = $parser->Entity(); unless ($Message) { MailError( - To => $RT::OwnerEmail, Subject => "RT Bounce: Unparseable message", Explanation => "RT couldn't process the message below", Attach => $args{'message'} @@ -565,12 +1289,50 @@ sub Gateway { ); } - my $head = $Message->head; + my @mail_plugins = grep $_, RT->Config->Get('MailPlugins'); + push @mail_plugins, "Auth::MailFrom" unless @mail_plugins; + @mail_plugins = _LoadPlugins( @mail_plugins ); + + my %skip_plugin; + foreach my $class( grep !ref, @mail_plugins ) { + # check if we should apply filter before decoding + my $check_cb = do { + no strict 'refs'; + *{ $class . "::ApplyBeforeDecode" }{CODE}; + }; + next unless defined $check_cb; + next unless $check_cb->( + Message => $Message, + RawMessageRef => \$args{'message'}, + ); - my $ErrorsTo = ParseErrorsToAddressFromHead($head); + $skip_plugin{ $class }++; + + my $Code = do { + no strict 'refs'; + *{ $class . "::GetCurrentUser" }{CODE}; + }; + my ($status, $msg) = $Code->( + Message => $Message, + RawMessageRef => \$args{'message'}, + ); + next if $status > 0; + + if ( $status == -2 ) { + return (1, $msg, undef); + } elsif ( $status == -1 ) { + return (0, $msg, undef); + } + } + @mail_plugins = grep !$skip_plugin{"$_"}, @mail_plugins; + $parser->_DecodeBodies; + $parser->_PostProcessNewEntity; + + my $head = $Message->head; + my $ErrorsTo = ParseErrorsToAddressFromHead( $head ); my $MessageId = $head->get('Message-ID') - || "<no-message-id-" . time . rand(2000) . "\@.$RT::Organization>"; + || "<no-message-id-". time . rand(2000) .'@'. RT->Config->Get('Organization') .'>'; #Pull apart the subject line my $Subject = $head->get('Subject') || ''; @@ -591,10 +1353,11 @@ sub Gateway { if ($IsALoop && !$should_store_machine_generated_message) { return ( 0, $result, undef ); } + # }}} - $args{'ticket'} ||= ParseTicketId($Subject); + $args{'ticket'} ||= ParseTicketId( $Subject ); - $SystemTicket = RT::Ticket->new($RT::SystemUser); + $SystemTicket = RT::Ticket->new( $RT::SystemUser ); $SystemTicket->Load( $args{'ticket'} ) if ( $args{'ticket'} ) ; if ( $SystemTicket->id ) { $Right = 'ReplyToTicket'; @@ -603,7 +1366,7 @@ sub Gateway { } #Set up a queue object - my $SystemQueueObj = RT::Queue->new($RT::SystemUser); + my $SystemQueueObj = RT::Queue->new( $RT::SystemUser ); $SystemQueueObj->Load( $args{'queue'} ); # We can safely have no queue of we have a known-good ticket @@ -611,67 +1374,15 @@ sub Gateway { return ( -75, "RT couldn't find the queue: " . $args{'queue'}, undef ); } - # Authentication Level ($AuthStat) - # -1 - Get out. this user has been explicitly declined - # 0 - User may not do anything (Not used at the moment) - # 1 - Normal user - # 2 - User is allowed to specify status updates etc. a la enhanced-mailgate - my ( $CurrentUser, $AuthStat, $error ); - - # Initalize AuthStat so comparisons work correctly - $AuthStat = -9999999; - - push @RT::MailPlugins, "Auth::MailFrom" unless @RT::MailPlugins; - - # if plugin returns AuthStat -2 we skip action - # NOTE: this is experimental API and it would be changed - my %skip_action = (); - - # Since this needs loading, no matter what - foreach (@RT::MailPlugins) { - my ($Code, $NewAuthStat); - if ( ref($_) eq "CODE" ) { - $Code = $_; - } else { - my $Class = $_; - $Class = "RT::Interface::Email::" . $Class - unless $Class =~ /^RT::Interface::Email::/; - $Class->require or - do { $RT::Logger->error("Couldn't load $Class: $@"); next }; - - no strict 'refs'; - unless ( defined( $Code = *{ $Class . "::GetCurrentUser" }{CODE} ) ) { - $RT::Logger->crit( "No 'GetCurrentUser' function found in '$Class' module"); - next; - } - } - - foreach my $action (@actions) { - ( $CurrentUser, $NewAuthStat ) = $Code->( - Message => $Message, - RawMessageRef => \$args{'message'}, - CurrentUser => $CurrentUser, - AuthLevel => $AuthStat, - Action => $action, - Ticket => $SystemTicket, - Queue => $SystemQueueObj - ); - -# You get the highest level of authentication you were assigned, unless you get the magic -1 -# If a module returns a "-1" then we discard the ticket, so. - $AuthStat = $NewAuthStat - if ( $NewAuthStat > $AuthStat or $NewAuthStat == -1 or $NewAuthStat == -2 ); - - last if $AuthStat == -1; - $skip_action{$action}++ if $AuthStat == -2; - } - - # strip actions we should skip - @actions = grep !$skip_action{$_}, @actions if $AuthStat == -2; - last unless @actions; + my ($AuthStat, $CurrentUser, $error) = GetAuthenticationLevel( + MailPlugins => \@mail_plugins, + Actions => \@actions, + Message => $Message, + RawMessageRef => \$args{message}, + SystemTicket => $SystemTicket, + SystemQueue => $SystemQueueObj, + ); - last if $AuthStat == -1; - } # {{{ If authentication fails and no new user was created, get out. if ( !$CurrentUser || !$CurrentUser->id || $AuthStat == -1 ) { @@ -710,7 +1421,7 @@ sub Gateway { unless ($should_store_machine_generated_message) { return ( 0, $result, undef ); } - + # if plugin's updated SystemTicket then update arguments $args{'ticket'} = $SystemTicket->Id if $SystemTicket && $SystemTicket->Id; @@ -722,7 +1433,7 @@ sub Gateway { my @Cc; my @Requestors = ( $CurrentUser->id ); - if ($RT::ParseNewMessageForTicketCcs) { + if (RT->Config->Get('ParseNewMessageForTicketCcs')) { @Cc = ParseCcAddressesFromHead( Head => $head, CurrentUser => $CurrentUser, @@ -772,6 +1483,8 @@ sub Gateway { } # }}} + + my $unsafe_actions = RT->Config->Get('UnsafeEmailCommands'); foreach my $action (@actions) { # If the action is comment, add a comment. @@ -789,7 +1502,7 @@ sub Gateway { ); return ( 0, "Message not recorded: $msg", $Ticket ); } - } elsif ($RT::UnsafeEmailCommands) { + } elsif ($unsafe_actions) { my ( $status, $msg ) = _RunUnsafeAction( Action => $action, ErrorsTo => $ErrorsTo, @@ -803,6 +1516,79 @@ sub Gateway { return ( 1, "Success", $Ticket ); } +=head2 GetAuthenticationLevel + + # Authentication Level + # -1 - Get out. this user has been explicitly declined + # 0 - User may not do anything (Not used at the moment) + # 1 - Normal user + # 2 - User is allowed to specify status updates etc. a la enhanced-mailgate + +=cut + +sub GetAuthenticationLevel { + my %args = ( + MailPlugins => [], + Actions => [], + Message => undef, + RawMessageRef => undef, + SystemTicket => undef, + SystemQueue => undef, + @_, + ); + + my ( $CurrentUser, $AuthStat, $error ); + + # Initalize AuthStat so comparisons work correctly + $AuthStat = -9999999; + + # if plugin returns AuthStat -2 we skip action + # NOTE: this is experimental API and it would be changed + my %skip_action = (); + + # Since this needs loading, no matter what + foreach (@{ $args{MailPlugins} }) { + my ($Code, $NewAuthStat); + if ( ref($_) eq "CODE" ) { + $Code = $_; + } else { + no strict 'refs'; + $Code = *{ $_ . "::GetCurrentUser" }{CODE}; + } + + foreach my $action (@{ $args{Actions} }) { + ( $CurrentUser, $NewAuthStat ) = $Code->( + Message => $args{Message}, + RawMessageRef => $args{RawMessageRef}, + CurrentUser => $CurrentUser, + AuthLevel => $AuthStat, + Action => $action, + Ticket => $args{SystemTicket}, + Queue => $args{SystemQueue}, + ); + +# You get the highest level of authentication you were assigned, unless you get the magic -1 +# If a module returns a "-1" then we discard the ticket, so. + $AuthStat = $NewAuthStat + if ( $NewAuthStat > $AuthStat or $NewAuthStat == -1 or $NewAuthStat == -2 ); + + last if $AuthStat == -1; + $skip_action{$action}++ if $AuthStat == -2; + } + + # strip actions we should skip + @{$args{Actions}} = grep !$skip_action{$_}, @{$args{Actions}} + if $AuthStat == -2; + last unless @{$args{Actions}}; + + last if $AuthStat == -1; + } + + return $AuthStat if !wantarray; + + return ($AuthStat, $CurrentUser, $error); +} + sub _RunUnsafeAction { my %args = ( Action => undef, @@ -860,7 +1646,7 @@ sub _NoAuthorizedUserFound { # Notify the RT Admin of the failure. MailError( - To => $RT::OwnerEmail, + To => RT->Config->Get('OwnerEmail'), Subject => "Could not load a valid user", Explanation => <<EOT, RT could not load a valid user, and RT's configuration does not allow @@ -875,7 +1661,7 @@ EOT ); # Also notify the requestor that his request has been dropped. - if ($args{'Requestor'} ne $RT::OwnerEmail) { + if ($args{'Requestor'} ne RT->Config->Get('OwnerEmail')) { MailError( To => $args{'Requestor'}, Subject => "Could not load a valid user", @@ -918,11 +1704,13 @@ sub _HandleMachineGeneratedMail { my $SquelchReplies = 0; + my $owner_mail = RT->Config->Get('OwnerEmail'); + #If the message is autogenerated, we need to know, so we can not # send mail to the sender if ( $IsBounce || $IsSuspiciousSender || $IsAutoGenerated || $IsALoop ) { $SquelchReplies = 1; - $ErrorsTo = $RT::OwnerEmail; + $ErrorsTo = $owner_mail; } # Warn someone if it's a loop, before we drop it on the ground @@ -930,9 +1718,9 @@ sub _HandleMachineGeneratedMail { $RT::Logger->crit("RT Received mail (".$args{MessageId}.") from itself."); #Should we mail it to RTOwner? - if ($RT::LoopsToRTOwner) { + if ( RT->Config->Get('LoopsToRTOwner') ) { MailError( - To => $RT::OwnerEmail, + To => $owner_mail, Subject => "RT Bounce: ".$args{'Subject'}, Explanation => "RT thinks this message may be a bounce", MIMEObj => $args{Message} @@ -940,7 +1728,8 @@ sub _HandleMachineGeneratedMail { } #Do we actually want to store it? - return ( 0, $ErrorsTo, "Message Bounced", $IsALoop ) unless ($RT::StoreLoops); + return ( 0, $ErrorsTo, "Message Bounced", $IsALoop ) + unless RT->Config->Get('StoreLoops'); } # Squelch replies if necessary @@ -977,7 +1766,7 @@ sub IsCorrectAction { my $action = shift; my @actions = grep $_, split /-/, $action; return ( 0, '(no value)' ) unless @actions; - foreach (@actions) { + foreach ( @actions ) { return ( 0, $_ ) unless /^(?:comment|correspond|take|resolve)$/; } return ( 1, @actions ); diff --git a/rt/lib/RT/Interface/Email/Auth/GnuPG.pm b/rt/lib/RT/Interface/Email/Auth/GnuPG.pm index e543c4b24..df987d806 100755 --- a/rt/lib/RT/Interface/Email/Auth/GnuPG.pm +++ b/rt/lib/RT/Interface/Email/Auth/GnuPG.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,70 +45,204 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} -# + package RT::Interface::Email::Auth::GnuPG; -use Mail::GnuPG; + +use strict; +use warnings; =head2 GetCurrentUser To use the gnupg-secured mail gateway, you need to do the following: -Set up a gnupgp key directory with a pubring containing only the keys +Set up a GnuPG key directory with a pubring containing only the keys you care about and specify the following in your SiteConfig.pm -Set($RT::GPGKeyDir, "/path/to/keyring-directory"); -@RT::MailPlugins = qw(Auth::MailFrom Auth::GnuPG Filter::TakeAction); - - + Set(%GnuPGOptions, homedir => '/opt/rt3/var/data/GnuPG'); + Set(@MailPlugins, 'Auth::MailFrom', 'Auth::GnuPG', ...other filter...); =cut +sub ApplyBeforeDecode { return 1 } +use RT::Crypt::GnuPG; +use RT::EmailParser (); sub GetCurrentUser { my %args = ( - Message => undef, - RawMessageRef => undef, - CurrentUser => undef, - AuthLevel => undef, - Ticket => undef, - Queue => undef, - Action => undef, + Message => undef, + RawMessageRef => undef, @_ ); - my ( $val, $key, $address,$gpg ); + $args{'Message'}->head->delete($_) + for qw(X-RT-GnuPG-Status X-RT-Incoming-Encrypton + X-RT-Incoming-Signature X-RT-Privacy); + + my $msg = $args{'Message'}->dup; + + my ($status, @res) = VerifyDecrypt( Entity => $args{'Message'} ); + if ( $status && !@res ) { + $args{'Message'}->head->add( + 'X-RT-Incoming-Encryption' => 'Not encrypted' + ); + + return 1; + } + + # FIXME: Check if the message is encrypted to the address of + # _this_ queue. send rejecting mail otherwise. + + unless ( $status ) { + $RT::Logger->error("Had a problem during decrypting and verifying"); + my $reject = HandleErrors( Message => $args{'Message'}, Result => \@res ); + return (0, 'rejected because of problems during decrypting and verifying') + if $reject; + } + + # attach the original encrypted message + $args{'Message'}->attach( + Type => 'application/x-rt-original-message', + Disposition => 'inline', + Data => ${ $args{'RawMessageRef'} }, + ); + + $args{'Message'}->head->add( 'X-RT-Privacy' => 'PGP' ); - eval { + foreach my $part ( $args{'Message'}->parts_DFS ) { + my $decrypted; - my $parser = RT::EmailParser->new(); - $parser->SmartParseMIMEEntityFromScalar(Message => ${$args{'RawMessageRef'}}, Decode => 0); - $gpg = Mail::GnuPG->new( keydir => $RT::GPGKeyDir ); - my $entity = $parser->Entity; - ( $val, $key, $address ) = $gpg->verify( $parser->Entity); - $RT::Logger->crit("Got $val - $key - $address"); - }; - - if ($@) { - $RT::Logger->crit($@); + my $status = $part->head->get( 'X-RT-GnuPG-Status' ); + if ( $status ) { + for ( RT::Crypt::GnuPG::ParseStatus( $status ) ) { + if ( $_->{Operation} eq 'Decrypt' && $_->{Status} eq 'DONE' ) { + $decrypted = 1; + } + if ( $_->{Operation} eq 'Verify' && $_->{Status} eq 'DONE' ) { + $part->head->add( + 'X-RT-Incoming-Signature' => $_->{UserString} + ); + } + } } - unless ($address) { - $RT::Logger->crit( "Couldn't find a valid signature" . join ( "\n", @{ $gpg->{'last_message'} } ) ); - return ( $args{'CurrentUser'}, $args{'AuthLevel'} ); + $part->head->add( + 'X-RT-Incoming-Encryption' => + $decrypted ? 'Success' : 'Not encrypted' + ); } - my @addrs = Mail::Address->parse($address); - $address = $addrs[0]->address(); + return 1; +} + +sub HandleErrors { + my %args = ( + Message => undef, + Result => [], + @_ + ); - my $CurrentUser = RT::CurrentUser->new(); - $CurrentUser->LoadByEmail($address); + my $reject = 0; - if ( $CurrentUser->Id ) { - $RT::Logger->crit($address . " authenticated via PGP signature"); - return ( $CurrentUser, 2 ); + my %sent_once = (); + foreach my $run ( @{ $args{'Result'} } ) { + my @status = RT::Crypt::GnuPG::ParseStatus( $run->{'status'} ); + unless ( $sent_once{'NoPrivateKey'} ) { + unless ( CheckNoPrivateKey( Message => $args{'Message'}, Status => \@status ) ) { + $sent_once{'NoPrivateKey'}++; + $reject = 1 if RT->Config->Get('GnuPG')->{'RejectOnMissingPrivateKey'}; + } + } + unless ( $sent_once{'BadData'} ) { + unless ( CheckBadData( Message => $args{'Message'}, Status => \@status ) ) { + $sent_once{'BadData'}++; + $reject = 1 if RT->Config->Get('GnuPG')->{'RejectOnBadData'}; + } + } } + return $reject; +} + +sub CheckNoPrivateKey { + my %args = (Message => undef, Status => [], @_ ); + my @status = @{ $args{'Status'} }; + my @decrypts = grep $_->{'Operation'} eq 'Decrypt', @status; + return 1 unless @decrypts; + foreach my $action ( @decrypts ) { + # if at least one secrete key exist then it's another error + return 1 if + grep !$_->{'User'}{'SecretKeyMissing'}, + @{ $action->{'EncryptedTo'} }; + } + + $RT::Logger->error("Couldn't decrypt a message: have no private key"); + + my $address = (RT::Interface::Email::ParseSenderAddressFromHead( $args{'Message'}->head ))[0]; + my ($status) = RT::Interface::Email::SendEmailUsingTemplate( + To => $address, + Template => 'Error: no private key', + Arguments => { + Message => $args{'Message'}, + TicketObj => $args{'Ticket'}, + }, + InReplyTo => $args{'Message'}, + ); + unless ( $status ) { + $RT::Logger->error("Couldn't send 'Error: no private key'"); + } + return 0; +} + +sub CheckBadData { + my %args = (Message => undef, Status => [], @_ ); + my @bad_data_messages = + map $_->{'Message'}, + grep $_->{'Status'} ne 'DONE' && $_->{'Operation'} eq 'Data', + @{ $args{'Status'} }; + return 1 unless @bad_data_messages; + + $RT::Logger->error("Couldn't process a message: ". join ', ', @bad_data_messages ); + + my $address = (RT::Interface::Email::ParseSenderAddressFromHead( $args{'Message'}->head ))[0]; + my ($status) = RT::Interface::Email::SendEmailUsingTemplate( + To => $address, + Template => 'Error: bad GnuPG data', + Arguments => { + Messages => [ @bad_data_messages ], + TicketObj => $args{'Ticket'}, + }, + InReplyTo => $args{'Message'}, + ); + unless ( $status ) { + $RT::Logger->error("Couldn't send 'Error: bad GnuPG data'"); + } + return 0; +} + +sub VerifyDecrypt { + my %args = ( + Entity => undef, + @_ + ); + + my @res = RT::Crypt::GnuPG::VerifyDecrypt( %args ); + unless ( @res ) { + $RT::Logger->debug("No more encrypted/signed parts"); + return 1; + } + + $RT::Logger->debug('Found GnuPG protected parts'); + + # return on any error + if ( grep $_->{'exit_code'}, @res ) { + $RT::Logger->debug("Error during verify/decrypt operation"); + return (0, @res); + } + + # nesting + my ($status, @nested) = VerifyDecrypt( %args ); + return $status, @res, @nested; } eval "require RT::Interface::Email::Auth::GnuPG_Vendor"; @@ -121,3 +255,4 @@ die $@ && $@ !~ qr{^Can't locate RT/Interface/Email/Auth/GnuPG_Local.pm} ); 1; + diff --git a/rt/lib/RT/Interface/Email/Auth/MailFrom.pm b/rt/lib/RT/Interface/Email/Auth/MailFrom.pm index 71cdf606c..0673c735c 100644 --- a/rt/lib/RT/Interface/Email/Auth/MailFrom.pm +++ b/rt/lib/RT/Interface/Email/Auth/MailFrom.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Email::Auth::MailFrom; use RT::Interface::Email qw(ParseSenderAddressFromHead CreateUser); @@ -62,84 +63,74 @@ sub GetCurrentUser { # We don't need to do any external lookups my ( $Address, $Name ) = ParseSenderAddressFromHead( $args{'Message'}->head ); - - unless ($Address) { + unless ( $Address ) { + $RT::Logger->error("Couldn't find sender's address"); return ( $args{'CurrentUser'}, -1 ); } - my $CurrentUser = RT::CurrentUser->new(); - $CurrentUser->LoadByEmail($Address); - - unless ( $CurrentUser->Id ) { - $CurrentUser->LoadByName($Address); - } - + my $CurrentUser = new RT::CurrentUser; + $CurrentUser->LoadByEmail( $Address ); + $CurrentUser->LoadByName( $Address ) unless $CurrentUser->Id; if ( $CurrentUser->Id ) { + $RT::Logger->debug("Mail from user #". $CurrentUser->Id ." ($Address)" ); return ( $CurrentUser, 1 ); } - - # If the user can't be loaded, we may need to create one. Figure out the acl situation. - my $unpriv = RT::Group->new($RT::SystemUser); + my $unpriv = RT::Group->new( $RT::SystemUser ); $unpriv->LoadSystemInternalGroup('Unprivileged'); unless ( $unpriv->Id ) { - $RT::Logger->crit( "Auth::MailFrom couldn't find the 'Unprivileged' internal group" ); + $RT::Logger->crit("Couldn't find the 'Unprivileged' internal group"); return ( $args{'CurrentUser'}, -1 ); } - my $everyone = RT::Group->new($RT::SystemUser); + my $everyone = RT::Group->new( $RT::SystemUser ); $everyone->LoadSystemInternalGroup('Everyone'); unless ( $everyone->Id ) { - $RT::Logger->crit( "Auth::MailFrom couldn't find the 'Everyone' internal group"); + $RT::Logger->crit("Couldn't find the 'Everyone' internal group"); return ( $args{'CurrentUser'}, -1 ); } + $RT::Logger->debug("Going to create user with address '$Address'" ); + # but before we do that, we need to make sure that the created user would have the right # to do what we're doing. if ( $args{'Ticket'} && $args{'Ticket'}->Id ) { + my $qname = $args{'Queue'}->Name; # We have a ticket. that means we're commenting or corresponding if ( $args{'Action'} =~ /^comment$/i ) { # check to see whether "Everyone" or "Unprivileged users" can comment on tickets - unless ( $everyone->PrincipalObj->HasRight( - Object => $args{'Queue'}, - Right => 'CommentOnTicket' - ) - || $unpriv->PrincipalObj->HasRight( - Object => $args{'Queue'}, - Right => 'CommentOnTicket' - ) - ) { + unless ( $everyone->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'CommentOnTicket' ) + || $unpriv->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'CommentOnTicket' ) ) + { + $RT::Logger->debug("Unprivileged users have no right to comment on ticket in queue '$qname'"); return ( $args{'CurrentUser'}, 0 ); } } elsif ( $args{'Action'} =~ /^correspond$/i ) { # check to see whether "Everybody" or "Unprivileged users" can correspond on tickets - unless ( $everyone->PrincipalObj->HasRight(Object => $args{'Queue'}, - Right => 'ReplyToTicket' - ) - || $unpriv->PrincipalObj->HasRight( - Object => $args{'Queue'}, - Right => 'ReplyToTicket' - ) - ) { + unless ( $everyone->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'ReplyToTicket' ) + || $unpriv->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'ReplyToTicket' ) ) + { + $RT::Logger->debug("Unprivileged users have no right to reply to ticket in queue '$qname'"); return ( $args{'CurrentUser'}, 0 ); } - } elsif ( $args{'Action'} =~ /^take$/i ) { # check to see whether "Everybody" or "Unprivileged users" can correspond on tickets - unless ( $everyone->PrincipalObj->HasRight(Object => $args{'Queue'}, - Right => 'OwnTicket' - ) - || $unpriv->PrincipalObj->HasRight( - Object => $args{'Queue'}, - Right => 'OwnTicket' - ) - ) { + unless ( $everyone->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'OwnTicket' ) + || $unpriv->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'OwnTicket' ) ) + { + $RT::Logger->debug("Unprivileged users have no right to own ticket in queue '$qname'"); return ( $args{'CurrentUser'}, 0 ); } @@ -147,33 +138,35 @@ sub GetCurrentUser { elsif ( $args{'Action'} =~ /^resolve$/i ) { # check to see whether "Everybody" or "Unprivileged users" can correspond on tickets - unless ( $everyone->PrincipalObj->HasRight(Object => $args{'Queue'}, - Right => 'ModifyTicket' - ) - || $unpriv->PrincipalObj->HasRight( - Object => $args{'Queue'}, - Right => 'ModifyTicket' - ) - ) { + unless ( $everyone->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'ModifyTicket' ) + || $unpriv->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'ModifyTicket' ) ) + { + $RT::Logger->debug("Unprivileged users have no right to resolve ticket in queue '$qname'"); return ( $args{'CurrentUser'}, 0 ); } } else { + $RT::Logger->warning("Action '". ($args{'Action'}||'') ."' is unknown"); return ( $args{'CurrentUser'}, 0 ); } } # We're creating a ticket elsif ( $args{'Queue'} && $args{'Queue'}->Id ) { + my $qname = $args{'Queue'}->Name; # check to see whether "Everybody" or "Unprivileged users" can create tickets in this queue unless ( $everyone->PrincipalObj->HasRight( Object => $args{'Queue'}, Right => 'CreateTicket' ) - ) { + || $unpriv->PrincipalObj->HasRight( Object => $args{'Queue'}, + Right => 'CreateTicket' ) ) + { + $RT::Logger->debug("Unprivileged users have no right to create ticket in queue '$qname'"); return ( $args{'CurrentUser'}, 0 ); } - } $CurrentUser = CreateUser( undef, $Address, $Name, $Address, $args{'Message'} ); diff --git a/rt/lib/RT/Interface/Email/Filter/SpamAssassin.pm b/rt/lib/RT/Interface/Email/Filter/SpamAssassin.pm index 176b39414..49e89c570 100644 --- a/rt/lib/RT/Interface/Email/Filter/SpamAssassin.pm +++ b/rt/lib/RT/Interface/Email/Filter/SpamAssassin.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Email::Filter::SpamAssassin; use Mail::SpamAssassin; @@ -77,7 +78,8 @@ RT::Interface::Email::Filter::SpamAssassin - Spam filter for RT =head1 SYNOPSIS - @RT::MailPlugins = ("Filter::SpamAssassin", ...); + # in RT config + Set(@MailPlugins, 'Filter::SpamAssassin', ...other filters...); =head1 DESCRIPTION diff --git a/rt/lib/RT/Interface/REST.pm b/rt/lib/RT/Interface/REST.pm index 9fef6f896..e7689f4f8 100644 --- a/rt/lib/RT/Interface/REST.pm +++ b/rt/lib/RT/Interface/REST.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + # lib/RT/Interface/REST.pm # @@ -53,16 +54,39 @@ use strict; use RT; BEGIN { - use Exporter (); - use vars qw($VERSION @ISA @EXPORT); + use base 'Exporter'; + use vars qw($VERSION @EXPORT); - $VERSION = do { my @r = (q$Revision: 1.1.1.7 $ =~ /\d+/g); sprintf "%d."."%02d"x$#r, @r }; + $VERSION = do { my @r = (q$Revision: 1.1.1.8 $ =~ /\d+/g); sprintf "%d."."%02d"x$#r, @r }; - @ISA = qw(Exporter); @EXPORT = qw(expand_list form_parse form_compose vpush vsplit); } -my $field = '(?i:[a-z][a-z0-9_-]*|C(?:ustom)?F(?:ield)?-(?:[a-z0-9_ -]|\s)+)'; +sub custom_field_spec { + my $self = shift; + my $capture = shift; + + my $CF_char = '[\sa-z0-9_ :()/-]'; + my $CF_name = $CF_char . '+'; + $CF_name = '(' . $CF_name . ')' if $capture; + + my $new_style = 'CF\.\{'.$CF_name.'\}'; + my $old_style = 'C(?:ustom)?F(?:ield)?-'.$CF_name; + + return '(?i:' . join('|', $new_style, $old_style) . ')'; +} + +sub field_spec { + my $self = shift; + my $capture = shift; + + my $field = '[a-z][a-z0-9_-]*'; + $field = '(' . $field . ')' if $capture; + + my $custom_field = __PACKAGE__->custom_field_spec($capture); + + return '(?i:' . join('|', $field, $custom_field) . ')'; +} # WARN: this code is duplicated in bin/rt.in, # change both functions at once @@ -95,6 +119,7 @@ sub form_parse { my @forms = (); my @lines = split /\n/, $_[0]; my ($c, $o, $k, $e) = ("", [], {}, ""); + my $field = __PACKAGE__->field_spec; LINE: while (@lines) { @@ -148,7 +173,7 @@ sub form_parse { $state = 1; } - elsif ($line !~ /^#/) { + elsif ($line =~ /^#/) { # We've found a syntax error, so we'll reconstruct the # form parsed thus far, and add an error marker. (>>) $state = -1; @@ -201,7 +226,8 @@ sub form_compose { $sp = " "x4 if length($sp) > 16; foreach $v (@values) { - if ($v =~ /\n/) { + $v = '' unless defined $v; + if ( $v =~ /\n/) { $v =~ s/^/$sp/gm; $v =~ s/^$sp//; @@ -269,9 +295,9 @@ sub vsplit { my ($val) = @_; my ($line, $word, @words); - foreach $line (map {split /\n/} (ref $val eq 'ARRAY') ? @$val : $val) + foreach $line (map {split /\n/} (ref $val eq 'ARRAY') ? @$val : ($val||'')) { - # XXX: This should become a real parser, à la Text::ParseWords. + # XXX: This should become a real parser, ? la Text::ParseWords. $line =~ s/^\s+//; $line =~ s/\s+$//; push @words, split /\s*,\s*/, $line; @@ -280,6 +306,16 @@ sub vsplit { return \@words; } +eval "require RT::Interface::REST_Vendor"; +if ($@ && $@ !~ qr{^Can't locate RT/Interface/REST_Vendor.pm}) { + die $@; +}; + +eval "require RT::Interface::REST_Local"; +if ($@ && $@ !~ qr{^Can't locate RT/Interface/REST_Local.pm}) { + die $@; +}; + 1; =head1 NAME diff --git a/rt/lib/RT/Interface/Web.pm b/rt/lib/RT/Interface/Web.pm index 4e5fca151..b4279fb4b 100644 --- a/rt/lib/RT/Interface/Web.pm +++ b/rt/lib/RT/Interface/Web.pm @@ -1,40 +1,40 @@ # BEGIN BPS TAGGED BLOCK {{{ -# +# # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> -# +# # (Except where explicitly superseded by other copyright notices) -# -# +# +# # LICENSE: -# +# # This work is made available to you under the terms of Version 2 of # the GNU General Public License. A copy of that license should have # been provided with this software, but in any event can be snarfed # from www.gnu.org. -# +# # This work 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. See the GNU # General Public License for more details. -# +# # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301 or visit their web page on the internet at # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. -# -# +# +# # CONTRIBUTION SUBMISSION POLICY: -# +# # (The following paragraph is not intended to limit the rights granted # to you to modify and distribute this software under the terms of # the GNU General Public License and is only of importance to you if # you choose to contribute your changes and enhancements to the # community by submitting them to Best Practical Solutions, LLC.) -# +# # By intentionally submitting any modifications, corrections or # derivatives to this work, or any other work intended for use with # Request Tracker, to Best Practical Solutions, LLC, you confirm that @@ -43,34 +43,31 @@ # royalty-free, perpetual, license to use, copy, create derivative # works based on those contributions, and sublicense and distribute # those contributions and any derivatives thereof. -# +# # END BPS TAGGED BLOCK }}} + ## Portions Copyright 2000 Tobias Brox <tobix@fsck.com> ## This is a library of static subs to be used by the Mason web ## interface to RT - =head1 NAME RT::Interface::Web -=begin testing - -use_ok(RT::Interface::Web); - -=end testing =cut - use strict; use warnings; package RT::Interface::Web; -use HTTP::Date; + use RT::SavedSearches; -use URI; +use URI qw(); +use RT::Interface::Web::Session; +use Digest::MD5 (); +use Encode qw(); # {{{ EscapeUTF8 @@ -80,22 +77,17 @@ does a css-busting but minimalist escaping of whatever html you're passing in. =cut -sub EscapeUTF8 { - my $ref = shift; - return unless defined $$ref; - my $val = $$ref; - use bytes; - $val =~ s/&/&/g; - $val =~ s/</</g; - $val =~ s/>/>/g; - $val =~ s/\(/(/g; - $val =~ s/\)/)/g; - $val =~ s/"/"/g; - $val =~ s/'/'/g; - $$ref = $val; - Encode::_utf8_on($$ref); - - +sub EscapeUTF8 { + my $ref = shift; + return unless defined $$ref; + + $$ref =~ s/&/&/g; + $$ref =~ s/</</g; + $$ref =~ s/>/>/g; + $$ref =~ s/\(/(/g; + $$ref =~ s/\)/)/g; + $$ref =~ s/"/"/g; + $$ref =~ s/'/'/g; } # }}} @@ -108,12 +100,12 @@ Escapes URI component according to RFC2396 =cut -use Encode qw(); sub EscapeURI { my $ref = shift; - $$ref = Encode::encode_utf8( $$ref ); + return unless defined $$ref; + + use bytes; $$ref =~ s/([^a-zA-Z0-9_.!~*'()-])/uc sprintf("%%%02X", ord($1))/eg; - Encode::_utf8_on( $$ref ); } # }}} @@ -129,13 +121,7 @@ just downcase $ENV{'REMOTE_USER'} =cut sub WebCanonicalizeInfo { - my $user; - - if ( defined $ENV{'REMOTE_USER'} ) { - $user = lc ( $ENV{'REMOTE_USER'} ) if( length($ENV{'REMOTE_USER'}) ); - } - - return $user; + return $ENV{'REMOTE_USER'} ? lc $ENV{'REMOTE_USER'} : $ENV{'REMOTE_USER'}; } # }}} @@ -155,19 +141,21 @@ sub WebExternalAutoInfo { # default to making Privileged users, even if they specify # some other default Attributes - if (!$RT::AutoCreate || - ( ref($RT::AutoCreate) && not exists $RT::AutoCreate->{Privileged})) { + if ( !$RT::AutoCreate + || ( ref($RT::AutoCreate) && not exists $RT::AutoCreate->{Privileged} ) ) + { $user_info{'Privileged'} = 1; } - if ($^O !~ /^(?:riscos|MacOS|MSWin32|dos|os2)$/) { + if ( $^O !~ /^(?:riscos|MacOS|MSWin32|dos|os2)$/ ) { + # Populate fields with information from Unix /etc/passwd - my ($comments, $realname) = (getpwnam($user))[5, 6]; + my ( $comments, $realname ) = ( getpwnam($user) )[ 5, 6 ]; $user_info{'Comments'} = $comments if defined $comments; $user_info{'RealName'} = $realname if defined $realname; - } - elsif ($^O eq 'MSWin32' and eval 'use Net::AdminMisc; 1') { + } elsif ( $^O eq 'MSWin32' and eval 'use Net::AdminMisc; 1' ) { + # Populate fields with information from NT domain controller } @@ -177,7 +165,323 @@ sub WebExternalAutoInfo { # }}} +sub HandleRequest { + my $ARGS = shift; + + $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8"); + + $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ]; + + # Roll back any dangling transactions from a previous failed connection + $RT::Handle->ForceRollback() if $RT::Handle->TransactionDepth; + + MaybeEnableSQLStatementLog(); + + # avoid reentrancy, as suggested by masonbook + local *HTML::Mason::Commands::session unless $HTML::Mason::Commands::m->is_subrequest; + + $HTML::Mason::Commands::m->autoflush( $HTML::Mason::Commands::m->request_comp->attr('AutoFlush') ) + if ( $HTML::Mason::Commands::m->request_comp->attr_exists('AutoFlush') ); + + DecodeARGS($ARGS); + PreprocessTimeUpdates($ARGS); + + MaybeShowInstallModePage(); + + $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS ); + SendSessionCookie(); + $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new() unless _UserLoggedIn(); + + MaybeShowNoAuthPage($ARGS); + + AttemptExternalAuth($ARGS) if RT->Config->Get('WebExternalAuthContinuous') or not _UserLoggedIn(); + + _ForceLogout() unless _UserLoggedIn(); + + # Process per-page authentication callbacks + $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Auth', CallbackPage => '/autohandler' ); + + unless ( _UserLoggedIn() ) { + _ForceLogout(); + + # If the user is logging in, let's authenticate + if ( defined $ARGS->{user} && defined $ARGS->{pass} ) { + AttemptPasswordAuthentication($ARGS); + } else { + # if no credentials then show him login page + $HTML::Mason::Commands::m->comp( '/Elements/Login', %$ARGS ); + $HTML::Mason::Commands::m->abort; + } + } + + # now it applies not only to home page, but any dashboard that can be used as a workspace + $HTML::Mason::Commands::session{'home_refresh_interval'} = $ARGS->{'HomeRefreshInterval'} + if ( $ARGS->{'HomeRefreshInterval'} ); + + # Process per-page global callbacks + $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Default', CallbackPage => '/autohandler' ); + + ShowRequestedPage($ARGS); + LogRecordedSQLStatements(); +} + +sub _ForceLogout { + + delete $HTML::Mason::Commands::session{'CurrentUser'}; +} + +sub _UserLoggedIn { + if ( $HTML::Mason::Commands::session{CurrentUser} && $HTML::Mason::Commands::session{'CurrentUser'}->id ) { + return 1; + } else { + return undef; + } + +} + +=head2 MaybeShowInstallModePage + +This function, called exclusively by RT's autohandler, dispatches +a request to RT's Installation workflow, only if Install Mode is enabled in the configuration file. + +If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler + +=cut + +sub MaybeShowInstallModePage { + return unless RT->InstallMode; + + my $m = $HTML::Mason::Commands::m; + if ( $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex') ) { + $m->call_next(); + } elsif ( $m->request_comp->path !~ '^(/+)Install/' ) { + RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "Install/index.html" ); + } else { + $m->call_next(); + } + $m->abort(); +} + +=head2 MaybeShowNoAuthPage \%ARGS + +This function, called exclusively by RT's autohandler, dispatches +a request to the page a user requested (but only if it matches the "noauth" regex. + +If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler + +=cut +sub MaybeShowNoAuthPage { + my $ARGS = shift; + + my $m = $HTML::Mason::Commands::m; + + return unless $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex'); + + # If it's a noauth file, don't ask for auth. + SendSessionCookie(); + $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS ); + $m->abort; +} + +=head2 ShowRequestedPage \%ARGS + +This function, called exclusively by RT's autohandler, dispatches +a request to the page a user requested (making sure that unpriviled users +can only see self-service pages. + +=cut + +sub ShowRequestedPage { + my $ARGS = shift; + + my $m = $HTML::Mason::Commands::m; + + SendSessionCookie(); + + # If the user isn't privileged, they can only see SelfService + unless ( $HTML::Mason::Commands::session{'CurrentUser'}->Privileged ) { + + # if the user is trying to access a ticket, redirect them + if ( $m->request_comp->path =~ '^(/+)Ticket/Display.html' && $ARGS->{'id'} ) { + RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/Display.html?id=" . $ARGS->{'id'} ); + } + + # otherwise, drop the user at the SelfService default page + elsif ( $m->base_comp->path !~ RT->Config->Get('SelfServiceRegex') ) { + RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/" ); + } + + # if user is in SelfService dir let him do anything + else { + $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS ); + } + } else { + $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS ); + } + +} + +sub AttemptExternalAuth { + my $ARGS = shift; + + return unless ( RT->Config->Get('WebExternalAuth') ); + + my $user = $ARGS->{user}; + my $m = $HTML::Mason::Commands::m; + + # If RT is configured for external auth, let's go through and get REMOTE_USER + + # do we actually have a REMOTE_USER equivlent? + if ( RT::Interface::Web::WebCanonicalizeInfo() ) { + my $orig_user = $user; + + $user = RT::Interface::Web::WebCanonicalizeInfo(); + my $load_method = RT->Config->Get('WebExternalGecos') ? 'LoadByGecos' : 'Load'; + + if ( $^O eq 'MSWin32' and RT->Config->Get('WebExternalGecos') ) { + my $NodeName = Win32::NodeName(); + $user =~ s/^\Q$NodeName\E\\//i; + } + + InstantiateNewSession() unless _UserLoggedIn; + $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new(); + $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user); + + if ( RT->Config->Get('WebExternalAuto') and not _UserLoggedIn() ) { + + # Create users on-the-fly + my $UserObj = RT::User->new($RT::SystemUser); + my ( $val, $msg ) = $UserObj->Create( + %{ ref RT->Config->Get('AutoCreate') ? RT->Config->Get('AutoCreate') : {} }, + Name => $user, + Gecos => $user, + ); + + if ($val) { + + # now get user specific information, to better create our user. + my $new_user_info = RT::Interface::Web::WebExternalAutoInfo($user); + + # set the attributes that have been defined. + foreach my $attribute ( $UserObj->WritableAttributes ) { + $m->callback( + Attribute => $attribute, + User => $user, + UserInfo => $new_user_info, + CallbackName => 'NewUser', + CallbackPage => '/autohandler' + ); + my $method = "Set$attribute"; + $UserObj->$method( $new_user_info->{$attribute} ) if defined $new_user_info->{$attribute}; + } + $HTML::Mason::Commands::session{'CurrentUser'}->Load($user); + } else { + + # we failed to successfully create the user. abort abort abort. + delete $HTML::Mason::Commands::session{'CurrentUser'}; + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc( 'Cannot create user: [_1]', $msg ) ) + if RT->Config->Get('WebFallbackToInternalAuth');; + $m->abort(); + } + } + + if ( _UserLoggedIn() ) { + $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' ); + } else { + delete $HTML::Mason::Commands::session{'CurrentUser'}; + $user = $orig_user; + + if ( RT->Config->Get('WebExternalOnly') ) { + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('You are not an authorized user') ); + $m->abort(); + } + } + } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) { + unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) { + # XXX unreachable due to prior defaulting in HandleRequest (check c34d108) + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('You are not an authorized user') ); + $m->abort(); + } + } else { + + # WebExternalAuth is set, but we don't have a REMOTE_USER. abort + # XXX: we must return AUTH_REQUIRED status or we fallback to + # internal auth here too. + delete $HTML::Mason::Commands::session{'CurrentUser'} + if defined $HTML::Mason::Commands::session{'CurrentUser'}; + } +} + +sub AttemptPasswordAuthentication { + my $ARGS = shift; + my $user_obj = RT::CurrentUser->new(); + $user_obj->Load( $ARGS->{user} ); + + my $m = $HTML::Mason::Commands::m; + + unless ( $user_obj->id && $user_obj->IsPassword( $ARGS->{pass} ) ) { + $RT::Logger->error("FAILED LOGIN for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}"); + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('Your username or password is incorrect'), ); + $m->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' ); + $m->abort; + } + + $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}"); + InstantiateNewSession(); + $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj; + $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' ); +} + +=head2 LoadSessionFromCookie + +Load or setup a session cookie for the current user. + +=cut + +sub _SessionCookieName { + my $cookiename = "RT_SID_" . RT->Config->Get('rtname'); + $cookiename .= "." . $ENV{'SERVER_PORT'} if $ENV{'SERVER_PORT'}; + return $cookiename; +} + +sub LoadSessionFromCookie { + + my %cookies = CGI::Cookie->fetch; + my $cookiename = _SessionCookieName(); + my $SessionCookie = ( $cookies{$cookiename} ? $cookies{$cookiename}->value : undef ); + tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', $SessionCookie; + unless ( $SessionCookie && $HTML::Mason::Commands::session{'_session_id'} eq $SessionCookie ) { + undef $cookies{$cookiename}; + } + if ( int RT->Config->Get('AutoLogoff') ) { + my $now = int( time / 60 ); + my $last_update = $HTML::Mason::Commands::session{'_session_last_update'} || 0; + + if ( $last_update && ( $now - $last_update - RT->Config->Get('AutoLogoff') ) > 0 ) { + InstantiateNewSession(); + } + + # save session on each request when AutoLogoff is turned on + $HTML::Mason::Commands::session{'_session_last_update'} = $now if $now != $last_update; + } +} + +sub InstantiateNewSession { + tied(%HTML::Mason::Commands::session)->delete if tied(%HTML::Mason::Commands::session); + tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', undef; +} + +sub SendSessionCookie { + my $cookie = CGI::Cookie->new( + -name => _SessionCookieName(), + -value => $HTML::Mason::Commands::session{_session_id}, + -path => RT->Config->Get('WebPath'), + -secure => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 ) + ); + + $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string; +} =head2 Redirect URL @@ -188,27 +492,42 @@ a cached DBI statement handle twice at the same time. =cut - sub Redirect { my $redir_to = shift; untie $HTML::Mason::Commands::session; - my $uri = URI->new($redir_to); - my $server_uri = URI->new($RT::WebURL); + my $uri = URI->new($redir_to); + my $server_uri = URI->new( RT->Config->Get('WebURL') ); # If the user is coming in via a non-canonical # hostname, don't redirect them to the canonical host, # it will just upset them (and invalidate their credentials) - if ($uri->host eq $server_uri->host && - $uri->port eq $server_uri->port) { - $uri->host($ENV{'HTTP_HOST'}); - $uri->port($ENV{'SERVER_PORT'}); + # don't do this if $RT::CanoniaclRedirectURLs is true + if ( !RT->Config->Get('CanonicalizeRedirectURLs') + && $uri->host eq $server_uri->host + && $uri->port eq $server_uri->port ) + { + if ( defined $ENV{HTTPS} and $ENV{'HTTPS'} eq 'on' ) { + $uri->scheme('https'); + } else { + $uri->scheme('http'); } - $HTML::Mason::Commands::m->redirect($uri->canonical); + # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST + $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} ); + $uri->port( $ENV{'SERVER_PORT'} ); + } + + # not sure why, but on some systems without this call mason doesn't + # set status to 302, but 200 instead and people see blank pages + $HTML::Mason::Commands::r->status(302); + + # Perlbal expects a status message, but Mason's default redirect status + # doesn't provide one. See also rt.cpan.org #36689. + $HTML::Mason::Commands::m->redirect( $uri->canonical, "302 Found" ); + $HTML::Mason::Commands::m->abort; } - =head2 StaticFileHeaders Send the browser a few headers to try to get it to (somewhat agressively) @@ -219,23 +538,196 @@ This routine could really use _accurate_ heuristics. (XXX TODO) =cut sub StaticFileHeaders { + my $date = RT::Date->new($RT::SystemUser); + # make cache public $HTML::Mason::Commands::r->headers_out->{'Cache-Control'} = 'max-age=259200, public'; # Expire things in a month. - $HTML::Mason::Commands::r->headers_out->{'Expires'} = HTTP::Date::time2str( time() + 2592000 ); + $date->Set( Value => time + 30 * 24 * 60 * 60 ); + $HTML::Mason::Commands::r->headers_out->{'Expires'} = $date->RFC2616; # if we set 'Last-Modified' then browser request a comp using 'If-Modified-Since' # request, but we don't handle it and generate full reply again # Last modified at server start time - #$HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = HTTP::Date::time2str($^T); + # $date->Set( Value => $^T ); + # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616; +} + +=head2 SendStaticFile + +Takes a File => path and a Type => Content-type + +If Type isn't provided and File is an image, it will +figure out a sane Content-type, otherwise it will +send application/octet-stream + +Will set caching headers using StaticFileHeaders + +=cut + +sub SendStaticFile { + my $self = shift; + my %args = @_; + my $file = $args{File}; + my $type = $args{Type}; + + $self->StaticFileHeaders(); + + unless ($type) { + if ( $file =~ /\.(gif|png|jpe?g)$/i ) { + $type = "image/$1"; + $type =~ s/jpg/jpeg/gi; + } + $type ||= "application/octet-stream"; + } + $HTML::Mason::Commands::r->content_type($type); + open my $fh, "<$file" or die "couldn't open file: $!"; + binmode($fh); + { + local $/ = \16384; + $HTML::Mason::Commands::m->out($_) while (<$fh>); + $HTML::Mason::Commands::m->flush_buffer; + } + close $fh; +} + +sub StripContent { + my %args = @_; + my $content = $args{Content}; + return '' unless $content; + + # Make the content have no 'weird' newlines in it + $content =~ s/\r+\n/\n/g; + + my $return_content = $content; + + my $html = $args{ContentType} && $args{ContentType} eq "text/html"; + my $sigonly = $args{StripSignature}; + + # massage content to easily detect if there's any real content + $content =~ s/\s+//g; # yes! remove all the spaces + if ( $html ) { + # remove html version of spaces and newlines + $content =~ s! !!g; + $content =~ s!<br/?>!!g; + } + # Filter empty content when type is text/html + return '' if $html && $content !~ /\S/; + + # If we aren't supposed to strip the sig, just bail now. + return $return_content unless $sigonly; + + # Find the signature + my $sig = $args{'CurrentUser'}->UserObj->Signature || ''; + $sig =~ s/\s+//g; + + # Check for plaintext sig + return '' if not $html and $content =~ /^(--)?\Q$sig\E$/; + + # Check for html-formatted sig + RT::Interface::Web::EscapeUTF8( \$sig ); + return '' + if $html + and $content =~ m{^(?:<p>)?(--)?\Q$sig\E(?:</p>)?$}s; + + # Pass it through + return $return_content; +} + +sub DecodeARGS { + my $ARGS = shift; + + %{$ARGS} = map { + + # if they've passed multiple values, they'll be an array. if they've + # passed just one, a scalar whatever they are, mark them as utf8 + my $type = ref($_); + ( !$type ) + ? Encode::is_utf8($_) + ? $_ + : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) + : ( $type eq 'ARRAY' ) + ? [ map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) } + @$_ ] + : ( $type eq 'HASH' ) + ? { map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) } + %$_ } + : $_ + } %$ARGS; } +sub PreprocessTimeUpdates { + my $ARGS = shift; + + # Later in the code we use + # $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %ARGS ); + # instead of $m->call_next to avoid problems with UTF8 keys in arguments. + # The call_next method pass through original arguments and if you have + # an argument with unicode key then in a next component you'll get two + # records in the args hash: one with key without UTF8 flag and another + # with the flag, which may result into errors. "{ base_comp => $m->request_comp }" + # is copied from mason's source to get the same results as we get from + # call_next method, this feature is not documented, so we just leave it + # here to avoid possible side effects. + + # This code canonicalizes time inputs in hours into minutes + foreach my $field ( keys %$ARGS ) { + next unless $field =~ /^(.*)-TimeUnits$/i && $ARGS->{$1}; + my $local = $1; + $ARGS->{$local} =~ s{\b (?: (\d+) \s+ )? (\d+)/(\d+) \b} + {($1 || 0) + $3 ? $2 / $3 : 0}xe; + if ( $ARGS->{$field} && $ARGS->{$field} =~ /hours/i ) { + $ARGS->{$local} *= 60; + } + delete $ARGS->{$field}; + } + +} + +sub MaybeEnableSQLStatementLog { + + my $log_sql_statements = RT->Config->Get('StatementLog'); + + if ($log_sql_statements) { + $RT::Handle->ClearSQLStatementLog; + $RT::Handle->LogSQLStatements(1); + } + +} + +sub LogRecordedSQLStatements { + my $log_sql_statements = RT->Config->Get('StatementLog'); + + return unless ($log_sql_statements); + + my @log = $RT::Handle->SQLStatementLog; + $RT::Handle->ClearSQLStatementLog; + for my $stmt (@log) { + my ( $time, $sql, $bind, $duration ) = @{$stmt}; + my @bind; + if ( ref $bind ) { + @bind = @{$bind}; + } else { + + # Older DBIx-SB + $duration = $bind; + } + $RT::Logger->log( + level => $log_sql_statements, + message => "SQL(" + . sprintf( "%.6f", $duration ) + . "s): $sql;" + . ( @bind ? " [ bound values: @{[map{qq|'$_'|} @bind]} ]" : "" ) + ); + } + +} package HTML::Mason::Commands; -use vars qw/$r $m %session/; +use vars qw/$r $m %session/; # {{{ loc @@ -250,14 +742,19 @@ through sub loc { - if ($session{'CurrentUser'} && - UNIVERSAL::can($session{'CurrentUser'}, 'loc')){ - return($session{'CurrentUser'}->loc(@_)); - } - elsif ( my $u = eval { RT::CurrentUser->new($RT::SystemUser->Id) } ) { - return ($u->loc(@_)); - } - else { + if ( $session{'CurrentUser'} + && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) ) + { + return ( $session{'CurrentUser'}->loc(@_) ); + } elsif ( + my $u = eval { + RT::CurrentUser->new(); + } + ) + { + return ( $u->loc(@_) ); + } else { + # pathetic case -- SystemUser is gone. return $_[0]; } @@ -265,7 +762,6 @@ sub loc { # }}} - # {{{ loc_fuzzy =head2 loc_fuzzy STRING @@ -279,40 +775,41 @@ inside the lexicon file. =cut sub loc_fuzzy { - my $msg = shift; - - if ($session{'CurrentUser'} && - UNIVERSAL::can($session{'CurrentUser'}, 'loc')){ - return($session{'CurrentUser'}->loc_fuzzy($msg)); - } - else { - my $u = RT::CurrentUser->new($RT::SystemUser->Id); - return ($u->loc_fuzzy($msg)); + my $msg = shift; + + if ( $session{'CurrentUser'} + && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) ) + { + return ( $session{'CurrentUser'}->loc_fuzzy($msg) ); + } else { + my $u = RT::CurrentUser->new( $RT::SystemUser->Id ); + return ( $u->loc_fuzzy($msg) ); } } # }}} - # {{{ sub Abort # Error - calls Error and aborts sub Abort { + my $why = shift; + my %args = @_; - if ($session{'ErrorDocument'} && - $session{'ErrorDocumentType'}) { - $r->content_type($session{'ErrorDocumentType'}); - $m->comp($session{'ErrorDocument'} , Why => shift); + if ( $session{'ErrorDocument'} + && $session{'ErrorDocumentType'} ) + { + $r->content_type( $session{'ErrorDocumentType'} ); + $m->comp( $session{'ErrorDocument'}, Why => $why, %args ); $m->abort; - } - else { - $m->comp("/Elements/Error" , Why => shift); + } else { + $m->comp( "/Elements/Error", Why => $why, %args ); $m->abort; } } # }}} -# {{{ sub CreateTicket +# {{{ sub CreateTicket =head2 CreateTicket ARGS @@ -336,17 +833,30 @@ sub CreateTicket { Abort('You have no permission to create tickets in that queue.'); } - my $due = new RT::Date( $session{'CurrentUser'} ); - $due->Set( Format => 'unknown', Value => $ARGS{'Due'} ); - my $starts = new RT::Date( $session{'CurrentUser'} ); - $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} ); + my $due; + if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) { + $due = new RT::Date( $session{'CurrentUser'} ); + $due->Set( Format => 'unknown', Value => $ARGS{'Due'} ); + } + my $starts; + if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) { + $starts = new RT::Date( $session{'CurrentUser'} ); + $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} ); + } + + my $sigless = RT::Interface::Web::StripContent( + Content => $ARGS{Content}, + ContentType => $ARGS{ContentType}, + StripSignature => 1, + CurrentUser => $session{'CurrentUser'}, + ); my $MIMEObj = MakeMIMEEntity( - Subject => $ARGS{'Subject'}, - From => $ARGS{'From'}, - Cc => $ARGS{'Cc'}, - Body => $ARGS{'Content'}, - Type => $ARGS{'ContentType'}, + Subject => $ARGS{'Subject'}, + From => $ARGS{'From'}, + Cc => $ARGS{'Cc'}, + Body => $sigless, + Type => $ARGS{'ContentType'}, ); if ( $ARGS{'Attachments'} ) { @@ -354,8 +864,8 @@ sub CreateTicket { $RT::Logger->error("Couldn't make multipart message") if !$rv || $rv !~ /^(?:DONE|ALREADY)$/; - foreach ( values %{$ARGS{'Attachments'}} ) { - unless ( $_ ) { + foreach ( values %{ $ARGS{'Attachments'} } ) { + unless ($_) { $RT::Logger->error("Couldn't add empty attachemnt"); next; } @@ -363,121 +873,120 @@ sub CreateTicket { } } + foreach my $argument (qw(Encrypt Sign)) { + $MIMEObj->head->add( "X-RT-$argument" => $ARGS{$argument} ) if defined $ARGS{$argument}; + } + my %create_args = ( - Type => $ARGS{'Type'} || 'ticket', - Queue => $ARGS{'Queue'}, - Owner => $ARGS{'Owner'}, + Type => $ARGS{'Type'} || 'ticket', + Queue => $ARGS{'Queue'}, + Owner => $ARGS{'Owner'}, + + # note: name change + Requestor => $ARGS{'Requestors'}, + Cc => $ARGS{'Cc'}, + AdminCc => $ARGS{'AdminCc'}, InitialPriority => $ARGS{'InitialPriority'}, FinalPriority => $ARGS{'FinalPriority'}, TimeLeft => $ARGS{'TimeLeft'}, - TimeEstimated => $ARGS{'TimeEstimated'}, + TimeEstimated => $ARGS{'TimeEstimated'}, TimeWorked => $ARGS{'TimeWorked'}, Subject => $ARGS{'Subject'}, Status => $ARGS{'Status'}, - Due => $due->ISO, - Starts => $starts->ISO, + Due => $due ? $due->ISO : undef, + Starts => $starts ? $starts->ISO : undef, MIMEObj => $MIMEObj ); my @temp_squelch; - foreach my $type (qw(Requestors Cc AdminCc)) { - my @tmp = map { $_->format } grep { $_->address} Mail::Address->parse( $ARGS{ $type } ); + foreach my $type (qw(Requestor Cc AdminCc)) { + push @temp_squelch, map $_->address, Email::Address->parse( $create_args{$type} ) + if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] }; + + } - $create_args{ $type } = [ - grep $_, map { - my $user = RT::User->new( $RT::SystemUser ); - $user->LoadOrCreateByEmail( $_ ); - # convert to ids to avoid work later - $user->id; - } @tmp - ]; - $RT::Logger->debug( - "$type got ".join(',',@{$create_args{ $type }}) ); + if (@temp_squelch) { + require RT::Action::SendEmail; + RT::Action::SendEmail->SquelchMailTo( RT::Action::SendEmail->SquelchMailTo, @temp_squelch ); + } + if ( $ARGS{'AttachTickets'} ) { + require RT::Action::SendEmail; + RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets, + ref $ARGS{'AttachTickets'} + ? @{ $ARGS{'AttachTickets'} } + : ( $ARGS{'AttachTickets'} ) ); } - # XXX: workaround for name conflict :( - $create_args{'Requestor'} = delete $create_args{'Requestors'}; - foreach my $arg (keys %ARGS) { + foreach my $arg ( keys %ARGS ) { next if $arg =~ /-(?:Magic|Category)$/; - if ($arg =~ /^Object-RT::Transaction--CustomField-/) { + if ( $arg =~ /^Object-RT::Transaction--CustomField-/ ) { $create_args{$arg} = $ARGS{$arg}; } + # Object-RT::Ticket--CustomField-3-Values - elsif ($arg =~ /^Object-RT::Ticket--CustomField-(\d+)(.*?)$/) { + elsif ( $arg =~ /^Object-RT::Ticket--CustomField-(\d+)/ ) { my $cfid = $1; - my $cf = RT::CustomField->new( $session{'CurrentUser'}); - $cf->Load($cfid); - if ( $cf->Type eq 'Freeform' && ! $cf->SingleValue) { - $ARGS{$arg} =~ s/\r\n/\n/g; - $ARGS{$arg} = [split('\n', $ARGS{$arg})]; - } - - if ( $cf->Type =~ /text/i) { # Catch both Text and Wikitext - $ARGS{$arg} =~ s/\r//g; + my $cf = RT::CustomField->new( $session{'CurrentUser'} ); + $cf->Load($cfid); + unless ( $cf->id ) { + $RT::Logger->error( "Couldn't load custom field #" . $cfid ); + next; } if ( $arg =~ /-Upload$/ ) { - $create_args{"CustomField-".$cfid} = _UploadedFile($arg); - } - else { - $create_args{"CustomField-".$cfid} = $ARGS{"$arg"}; + $create_args{"CustomField-$cfid"} = _UploadedFile($arg); + next; } - } - } + my $type = $cf->Type; - # XXX TODO This code should be about six lines. and badly needs refactoring. - - # {{{ turn new link lists into arrays, and pass in the proper arguments - my (@dependson, @dependedonby, @parents, @children, @refersto, @referredtoby); - - foreach my $luri ( split ( / /, $ARGS{"new-DependsOn"} ) ) { - $luri =~ s/\s*$//; # Strip trailing whitespace - push @dependson, $luri; - } - $create_args{'DependsOn'} = \@dependson; - - foreach my $luri ( split ( / /, $ARGS{"DependsOn-new"} ) ) { - push @dependedonby, $luri; - } - $create_args{'DependedOnBy'} = \@dependedonby; + my @values = (); + if ( ref $ARGS{$arg} eq 'ARRAY' ) { + @values = @{ $ARGS{$arg} }; + } elsif ( $type =~ /text/i ) { + @values = ( $ARGS{$arg} ); + } else { + no warnings 'uninitialized'; + @values = split /\r*\n/, $ARGS{$arg}; + } + @values = grep length, map { + s/\r+\n/\n/g; + s/^\s+//; + s/\s+$//; + $_; + } + grep defined, @values; - foreach my $luri ( split ( / /, $ARGS{"new-MemberOf"} ) ) { - $luri =~ s/\s*$//; # Strip trailing whitespace - push @parents, $luri; + $create_args{"CustomField-$cfid"} = \@values; + } } - $create_args{'Parents'} = \@parents; - foreach my $luri ( split ( / /, $ARGS{"MemberOf-new"} ) ) { - push @children, $luri; - } - $create_args{'Children'} = \@children; + # turn new link lists into arrays, and pass in the proper arguments + my %map = ( + 'new-DependsOn' => 'DependsOn', + 'DependsOn-new' => 'DependedOnBy', + 'new-MemberOf' => 'Parents', + 'MemberOf-new' => 'Children', + 'new-RefersTo' => 'RefersTo', + 'RefersTo-new' => 'ReferredToBy', + ); + foreach my $key ( keys %map ) { + next unless $ARGS{$key}; + $create_args{ $map{$key} } = [ grep $_, split ' ', $ARGS{$key} ]; - foreach my $luri ( split ( / /, $ARGS{"new-RefersTo"} ) ) { - $luri =~ s/\s*$//; # Strip trailing whitespace - push @refersto, $luri; } - $create_args{'RefersTo'} = \@refersto; - foreach my $luri ( split ( / /, $ARGS{"RefersTo-new"} ) ) { - push @referredtoby, $luri; - } - $create_args{'ReferredToBy'} = \@referredtoby; - # }}} - - my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args); - unless ( $id ) { + unless ($id) { Abort($ErrMsg); } - push ( @Actions, split("\n", $ErrMsg) ); + push( @Actions, split( "\n", $ErrMsg ) ); unless ( $Ticket->CurrentUserHasRight('ShowTicket') ) { - Abort( "No permission to view newly created ticket #" - . $Ticket->id . "." ); + Abort( "No permission to view newly created ticket #" . $Ticket->id . "." ); } return ( $Ticket, @Actions ); @@ -519,99 +1028,130 @@ sub LoadTicket { # {{{ sub ProcessUpdateMessage +=head2 ProcessUpdateMessage + +Takes paramhash with fields ARGSRef, TicketObj and SkipSignatureOnly. + +Don't write message if it only contains current user's signature and +SkipSignatureOnly argument is true. Function anyway adds attachments +and updates time worked field even if skips message. The default value +is true. + +=cut + sub ProcessUpdateMessage { - #TODO document what else this takes. my %args = ( - ARGSRef => undef, - Actions => undef, - TicketObj => undef, + ARGSRef => undef, + TicketObj => undef, + SkipSignatureOnly => 1, @_ ); - #Make the update content have no 'weird' newlines in it - if ( $args{ARGSRef}->{'UpdateTimeWorked'} - || $args{ARGSRef}->{'UpdateContent'} - || $args{ARGSRef}->{'UpdateAttachments'} ) + if ( $args{ARGSRef}->{'UpdateAttachments'} + && !keys %{ $args{ARGSRef}->{'UpdateAttachments'} } ) { + delete $args{ARGSRef}->{'UpdateAttachments'}; + } - if ( - $args{ARGSRef}->{'UpdateSubject'} eq $args{'TicketObj'}->Subject() ) - { - $args{ARGSRef}->{'UpdateSubject'} = undef; + # Strip the signature + $args{ARGSRef}->{UpdateContent} = RT::Interface::Web::StripContent( + Content => $args{ARGSRef}->{UpdateContent}, + ContentType => $args{ARGSRef}->{UpdateContentType}, + StripSignature => $args{SkipSignatureOnly}, + CurrentUser => $args{'TicketObj'}->CurrentUser, + ); + + # If, after stripping the signature, we have no message, move the + # UpdateTimeWorked into adjusted TimeWorked, so that a later + # ProcessBasics can deal -- then bail out. + if ( not $args{ARGSRef}->{'UpdateAttachments'} + and not length $args{ARGSRef}->{'UpdateContent'} ) + { + if ( $args{ARGSRef}->{'UpdateTimeWorked'} ) { + $args{ARGSRef}->{TimeWorked} = $args{TicketObj}->TimeWorked + delete $args{ARGSRef}->{'UpdateTimeWorked'}; } + return; + } - my $Message = MakeMIMEEntity( - Subject => $args{ARGSRef}->{'UpdateSubject'}, - Body => $args{ARGSRef}->{'UpdateContent'}, - Type => $args{ARGSRef}->{'UpdateContentType'}, - ); + if ( $args{ARGSRef}->{'UpdateSubject'} eq $args{'TicketObj'}->Subject ) { + $args{ARGSRef}->{'UpdateSubject'} = undef; + } - $Message->head->add( 'Message-ID' => - "<rt-" - . $RT::VERSION . "-" - . $$ . "-" - . CORE::time() . "-" - . int(rand(2000)) . "." - . $args{'TicketObj'}->id . "-" - . "0" . "-" # Scrip - . "0" . "@" # Email sent - . $RT::Organization - . ">" ); - my $old_txn = RT::Transaction->new( $session{'CurrentUser'} ); - if ( $args{ARGSRef}->{'QuoteTransaction'} ) { - $old_txn->Load( $args{ARGSRef}->{'QuoteTransaction'} ); - } - else { - $old_txn = $args{TicketObj}->Transactions->First(); - } + my $Message = MakeMIMEEntity( + Subject => $args{ARGSRef}->{'UpdateSubject'}, + Body => $args{ARGSRef}->{'UpdateContent'}, + Type => $args{ARGSRef}->{'UpdateContentType'}, + ); - if ( $old_txn->Message && $old_txn->Message->First ) { - my @in_reply_to = split(/\s+/m, $old_txn->Message->First->GetHeader('In-Reply-To') || ''); - my @references = split(/\s+/m, $old_txn->Message->First->GetHeader('References') || '' ); - my @msgid = split(/\s+/m,$old_txn->Message->First->GetHeader('Message-ID') || ''); - my @rtmsgid = split(/\s+/m,$old_txn->Message->First->GetHeader('RT-Message-ID') || ''); + $Message->head->add( 'Message-ID' => RT::Interface::Email::GenMessageId( Ticket => $args{'TicketObj'}, ) ); + my $old_txn = RT::Transaction->new( $session{'CurrentUser'} ); + if ( $args{ARGSRef}->{'QuoteTransaction'} ) { + $old_txn->Load( $args{ARGSRef}->{'QuoteTransaction'} ); + } else { + $old_txn = $args{TicketObj}->Transactions->First(); + } - $Message->head->replace( 'In-Reply-To', join (' ', @rtmsgid ? @rtmsgid : @msgid)); - $Message->head->replace( 'References', join(' ', @references, @msgid, @rtmsgid)); - } + if ( my $msg = $old_txn->Message->First ) { + RT::Interface::Email::SetInReplyTo( + Message => $Message, + InReplyTo => $msg + ); + } if ( $args{ARGSRef}->{'UpdateAttachments'} ) { $Message->make_multipart; - $Message->add_part($_) - foreach values %{ $args{ARGSRef}->{'UpdateAttachments'} }; + $Message->add_part($_) foreach values %{ $args{ARGSRef}->{'UpdateAttachments'} }; + } + + if ( $args{ARGSRef}->{'AttachTickets'} ) { + require RT::Action::SendEmail; + RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets, + ref $args{ARGSRef}->{'AttachTickets'} + ? @{ $args{ARGSRef}->{'AttachTickets'} } + : ( $args{ARGSRef}->{'AttachTickets'} ) ); + } + + my $bcc = $args{ARGSRef}->{'UpdateBcc'}; + my $cc = $args{ARGSRef}->{'UpdateCc'}; + + my %message_args = ( + CcMessageTo => $cc, + BccMessageTo => $bcc, + Sign => $args{ARGSRef}->{'Sign'}, + Encrypt => $args{ARGSRef}->{'Encrypt'}, + MIMEObj => $Message, + TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'} + ); + + unless ( $args{'ARGSRef'}->{'UpdateIgnoreAddressCheckboxes'} ) { + foreach my $key ( keys %{ $args{ARGSRef} } ) { + next unless $key =~ /^Update(Cc|Bcc)-(.*)$/; + + my $var = ucfirst($1) . 'MessageTo'; + my $value = $2; + if ( $message_args{$var} ) { + $message_args{$var} .= ", $value"; + } else { + $message_args{$var} = $value; + } + } } - ## TODO: Implement public comments + my @results; if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) { - my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment( - CcMessageTo => $args{ARGSRef}->{'UpdateCc'}, - BccMessageTo => $args{ARGSRef}->{'UpdateBcc'}, - MIMEObj => $Message, - TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'} - ); - push( @{ $args{Actions} }, $Description ); + my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment(%message_args); + push( @results, $Description ); $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object; - } - elsif ( $args{ARGSRef}->{'UpdateType'} eq 'response' ) { - my ( $Transaction, $Description, $Object ) = - $args{TicketObj}->Correspond( - CcMessageTo => $args{ARGSRef}->{'UpdateCc'}, - BccMessageTo => $args{ARGSRef}->{'UpdateBcc'}, - MIMEObj => $Message, - TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'} - ); - push( @{ $args{Actions} }, $Description ); + } elsif ( $args{ARGSRef}->{'UpdateType'} eq 'response' ) { + my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Correspond(%message_args); + push( @results, $Description ); $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object; + } else { + push( @results, + loc("Update type was neither correspondence nor comment.") . " " . loc("Update not recorded.") ); } - else { - push( - @{ $args{'Actions'} }, - loc("Update type was neither correspondence nor comment.") . " " - . loc("Update not recorded.") - ); - } -} + return @results; } # }}} @@ -638,301 +1178,65 @@ sub MakeMIMEEntity { Body => undef, AttachmentFieldName => undef, Type => undef, -# map Encode::encode_utf8($_), @_, @_, ); + my $Message = MIME::Entity->build( + Type => 'multipart/mixed', + Subject => $args{'Subject'} || "", + From => $args{'From'}, + Cc => $args{'Cc'}, + ); - #Make the update content have no 'weird' newlines in it + if ( defined $args{'Body'} && length $args{'Body'} ) { + + # Make the update content have no 'weird' newlines in it + $args{'Body'} =~ s/\r\n/\n/gs; - $args{'Body'} =~ s/\r\n/\n/gs if $args{'Body'}; - my $Message; - { # MIME::Head is not happy in utf-8 domain. This only happens # when processing an incoming email (so far observed). no utf8; use bytes; - $Message = MIME::Entity->build( - Subject => $args{'Subject'} || "", - From => $args{'From'}, - Cc => $args{'Cc'}, - Type => $args{'Type'} || 'text/plain', - 'Charset:' => 'utf8', - Data => [ $args{'Body'} ] + $Message->attach( + Type => $args{'Type'} || 'text/plain', + Charset => 'UTF-8', + Data => $args{'Body'}, ); } - my $cgi_object = $m->cgi_object; + if ( $args{'AttachmentFieldName'} ) { - if (my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} ) ) { + my $cgi_object = $m->cgi_object; + if ( my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} ) ) { - - use File::Temp qw(tempfile tempdir); - - #foreach my $filehandle (@filenames) { - - my ( $fh, $temp_file ); - for ( 1 .. 10 ) { - # on NFS and NTFS, it is possible that tempfile() conflicts - # with other processes, causing a race condition. we try to - # accommodate this by pausing and retrying. - last if ($fh, $temp_file) = eval { tempfile( UNLINK => 1) }; - sleep 1; - } - - binmode $fh; #thank you, windows - my ($buffer); - while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) { - print $fh $buffer; - } - - my $uploadinfo = $cgi_object->uploadInfo($filehandle); - - # Prefer the cached name first over CGI.pm stringification. - my $filename = $RT::Mason::CGI::Filename; - $filename = "$filehandle" unless defined($filename); - - $filename =~ s#^.*[\\/]##; - - $Message->attach( - Path => $temp_file, - Filename => Encode::decode_utf8($filename), - Type => $uploadinfo->{'Content-Type'}, - ); - close($fh); - - # } - - } - - $Message->make_singlepart(); - RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8 - - return ($Message); - -} - -# }}} - -# {{{ sub ProcessSearchQuery - -=head2 ProcessSearchQuery - - Takes a form such as the one filled out in webrt/Search/Elements/PickRestriction and turns it into something that RT::Tickets can understand. - -TODO Doc exactly what comes in the paramhash - - -=cut - -sub ProcessSearchQuery { - my %args = @_; - - ## TODO: The only parameter here is %ARGS. Maybe it would be - ## cleaner to load this parameter as $ARGS, and use $ARGS->{...} - ## instead of $args{ARGS}->{...} ? :) - - #Searches are sticky. - if ( defined $session{'tickets'} ) { - - # Reset the old search - $session{'tickets'}->GotoFirstItem; - } - else { - - # Init a new search - $session{'tickets'} = RT::Tickets->new( $session{'CurrentUser'} ); - } - - #Import a bookmarked search if we have one - if ( defined $args{ARGS}->{'Bookmark'} ) { - $session{'tickets'}->ThawLimits( $args{ARGS}->{'Bookmark'} ); - } - - # {{{ Goto next/prev page - if ( $args{ARGS}->{'GotoPage'} eq 'Next' ) { - $session{'tickets'}->NextPage; - } - elsif ( $args{ARGS}->{'GotoPage'} eq 'Prev' ) { - $session{'tickets'}->PrevPage; - } - elsif ( $args{ARGS}->{'GotoPage'} > 0 ) { - $session{'tickets'}->GotoPage( $args{ARGS}->{GotoPage} - 1 ); - } - - # }}} - - # {{{ Deal with limiting the search - - if ( $args{ARGS}->{'RefreshSearchInterval'} ) { - $session{'tickets_refresh_interval'} = - $args{ARGS}->{'RefreshSearchInterval'}; - } - - if ( $args{ARGS}->{'TicketsSortBy'} ) { - $session{'tickets_sort_by'} = $args{ARGS}->{'TicketsSortBy'}; - $session{'tickets_sort_order'} = $args{ARGS}->{'TicketsSortOrder'}; - $session{'tickets'}->OrderBy( - FIELD => $args{ARGS}->{'TicketsSortBy'}, - ORDER => $args{ARGS}->{'TicketsSortOrder'} - ); - } - - # }}} - - # {{{ Set the query limit - if ( defined $args{ARGS}->{'RowsPerPage'} ) { - $RT::Logger->debug( - "limiting to " . $args{ARGS}->{'RowsPerPage'} . " rows" ); - - $session{'tickets_rows_per_page'} = $args{ARGS}->{'RowsPerPage'}; - $session{'tickets'}->RowsPerPage( $args{ARGS}->{'RowsPerPage'} ); - } - - # }}} - # {{{ Limit priority - if ( $args{ARGS}->{'ValueOfPriority'} ne '' ) { - $session{'tickets'}->LimitPriority( - VALUE => $args{ARGS}->{'ValueOfPriority'}, - OPERATOR => $args{ARGS}->{'PriorityOp'} - ); - } - - # }}} - # {{{ Limit owner - if ( $args{ARGS}->{'ValueOfOwner'} ne '' ) { - $session{'tickets'}->LimitOwner( - VALUE => $args{ARGS}->{'ValueOfOwner'}, - OPERATOR => $args{ARGS}->{'OwnerOp'} - ); - } - - # }}} - # {{{ Limit requestor email - if ( $args{ARGS}->{'ValueOfWatcherRole'} ne '' ) { - $session{'tickets'}->LimitWatcher( - TYPE => $args{ARGS}->{'WatcherRole'}, - VALUE => $args{ARGS}->{'ValueOfWatcherRole'}, - OPERATOR => $args{ARGS}->{'WatcherRoleOp'}, - - ); - } - - # }}} - # {{{ Limit Queue - if ( $args{ARGS}->{'ValueOfQueue'} ne '' ) { - $session{'tickets'}->LimitQueue( - VALUE => $args{ARGS}->{'ValueOfQueue'}, - OPERATOR => $args{ARGS}->{'QueueOp'} - ); - } - - # }}} - # {{{ Limit Status - if ( $args{ARGS}->{'ValueOfStatus'} ne '' ) { - if ( ref( $args{ARGS}->{'ValueOfStatus'} ) ) { - foreach my $value ( @{ $args{ARGS}->{'ValueOfStatus'} } ) { - $session{'tickets'}->LimitStatus( - VALUE => $value, - OPERATOR => $args{ARGS}->{'StatusOp'}, - ); + my ( @content, $buffer ); + while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) { + push @content, $buffer; } - } - else { - $session{'tickets'}->LimitStatus( - VALUE => $args{ARGS}->{'ValueOfStatus'}, - OPERATOR => $args{ARGS}->{'StatusOp'}, - ); - } - } + my $uploadinfo = $cgi_object->uploadInfo($filehandle); - # }}} - # {{{ Limit Subject - if ( $args{ARGS}->{'ValueOfSubject'} ne '' ) { - my $val = $args{ARGS}->{'ValueOfSubject'}; - if ($args{ARGS}->{'SubjectOp'} =~ /like/) { - $val = "%".$val."%"; - } - $session{'tickets'}->LimitSubject( - VALUE => $val, - OPERATOR => $args{ARGS}->{'SubjectOp'}, - ); - } + # Prefer the cached name first over CGI.pm stringification. + my $filename = $RT::Mason::CGI::Filename; + $filename = "$filehandle" unless defined($filename); + $filename = Encode::decode_utf8($filename); + $filename =~ s{^.*[\\/]}{}; - # }}} - # {{{ Limit Dates - if ( $args{ARGS}->{'ValueOfDate'} ne '' ) { - my $date = ParseDateToISO( $args{ARGS}->{'ValueOfDate'} ); - $args{ARGS}->{'DateType'} =~ s/_Date$//; - - if ( $args{ARGS}->{'DateType'} eq 'Updated' ) { - $session{'tickets'}->LimitTransactionDate( - VALUE => $date, - OPERATOR => $args{ARGS}->{'DateOp'}, + $Message->attach( + Type => $uploadinfo->{'Content-Type'}, + Filename => $filename, + Data => \@content, ); - } - else { - $session{'tickets'}->LimitDate( FIELD => $args{ARGS}->{'DateType'}, - VALUE => $date, - OPERATOR => $args{ARGS}->{'DateOp'}, - ); - } - } - - # }}} - # {{{ Limit Content - if ( $args{ARGS}->{'ValueOfAttachmentField'} ne '' ) { - my $val = $args{ARGS}->{'ValueOfAttachmentField'}; - if ($args{ARGS}->{'AttachmentFieldOp'} =~ /like/) { - $val = "%".$val."%"; - } - $session{'tickets'}->Limit( - FIELD => $args{ARGS}->{'AttachmentField'}, - VALUE => $val, - OPERATOR => $args{ARGS}->{'AttachmentFieldOp'}, - ); - } - - # }}} - - # {{{ Limit CustomFields - - foreach my $arg ( keys %{ $args{ARGS} } ) { - my $id; - if ( $arg =~ /^CustomField(\d+)$/ ) { - $id = $1; - } - else { - next; - } - next unless ( $args{ARGS}->{$arg} ); - - my $form = $args{ARGS}->{$arg}; - my $oper = $args{ARGS}->{ "CustomFieldOp" . $id }; - foreach my $value ( ref($form) ? @{$form} : ($form) ) { - my $quote = 1; - if ($oper =~ /like/i) { - $value = "%".$value."%"; - } - if ( $value =~ /^null$/i ) { - - #Don't quote the string 'null' - $quote = 0; - - # Convert the operator to something apropriate for nulls - $oper = 'IS' if ( $oper eq '=' ); - $oper = 'IS NOT' if ( $oper eq '!=' ); + if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) { + $Message->head->set( 'Subject' => $filename ); } - $session{'tickets'}->LimitCustomField( CUSTOMFIELD => $id, - OPERATOR => $oper, - QUOTEVALUE => $quote, - VALUE => $value ); } } - # }}} + $Message->make_singlepart; + RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8 + return ($Message); } @@ -950,7 +1254,7 @@ Returns an ISO date and time in GMT sub ParseDateToISO { my $date = shift; - my $date_obj = RT::Date->new($session{'CurrentUser'}); + my $date_obj = RT::Date->new( $session{'CurrentUser'} ); $date_obj->Set( Format => 'unknown', Value => $date @@ -965,72 +1269,51 @@ sub ParseDateToISO { sub ProcessACLChanges { my $ARGSref = shift; - my %ARGS = %$ARGSref; - - my ( $ACL, @results ); - + #XXX: why don't we get ARGSref like in other Process* subs? - foreach my $arg (keys %ARGS) { - if ($arg =~ /GrantRight-(\d+)-(.*?)-(\d+)$/) { - my $principal_id = $1; - my $object_type = $2; - my $object_id = $3; - my $rights = $ARGS{$arg}; + my @results; - my $principal = RT::Principal->new($session{'CurrentUser'}); - $principal->Load($principal_id); + foreach my $arg ( keys %$ARGSref ) { + next unless ( $arg =~ /^(GrantRight|RevokeRight)-(\d+)-(.+?)-(\d+)$/ ); - my $obj; + my ( $method, $principal_id, $object_type, $object_id ) = ( $1, $2, $3, $4 ); - if ($object_type eq 'RT::System') { - $obj = $RT::System; - } elsif ($RT::ACE::OBJECT_TYPES{$object_type}) { - $obj = $object_type->new($session{'CurrentUser'}); - $obj->Load($object_id); - } else { - push (@results, loc("System Error"). ': '. - loc("Rights could not be granted for [_1]", $object_type)); - next; - } - - my @rights = ref($ARGS{$arg}) eq 'ARRAY' ? @{$ARGS{$arg}} : ($ARGS{$arg}); - foreach my $right (@rights) { - next unless ($right); - my ($val, $msg) = $principal->GrantRight(Object => $obj, Right => $right); - push (@results, $msg); - } + my @rights; + if ( UNIVERSAL::isa( $ARGSref->{$arg}, 'ARRAY' ) ) { + @rights = @{ $ARGSref->{$arg} }; + } else { + @rights = $ARGSref->{$arg}; } - elsif ($arg =~ /RevokeRight-(\d+)-(.*?)-(\d+)-(.*?)$/) { - my $principal_id = $1; - my $object_type = $2; - my $object_id = $3; - my $right = $4; - - my $principal = RT::Principal->new($session{'CurrentUser'}); - $principal->Load($principal_id); - next unless ($right); - my $obj; - - if ($object_type eq 'RT::System') { - $obj = $RT::System; - } elsif ($RT::ACE::OBJECT_TYPES{$object_type}) { - $obj = $object_type->new($session{'CurrentUser'}); - $obj->Load($object_id); - } else { - push (@results, loc("System Error"). ': '. - loc("Rights could not be revoked for [_1]", $object_type)); + @rights = grep $_, @rights; + next unless @rights; + + my $principal = RT::Principal->new( $session{'CurrentUser'} ); + $principal->Load($principal_id); + + my $obj; + if ( $object_type eq 'RT::System' ) { + $obj = $RT::System; + } elsif ( $RT::ACE::OBJECT_TYPES{$object_type} ) { + $obj = $object_type->new( $session{'CurrentUser'} ); + $obj->Load($object_id); + unless ( $obj->id ) { + $RT::Logger->error("couldn't load $object_type #$object_id"); next; } - my ($val, $msg) = $principal->RevokeRight(Object => $obj, Right => $right); - push (@results, $msg); + } else { + $RT::Logger->error("object type '$object_type' is incorrect"); + push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) ); + next; } - + foreach my $right (@rights) { + my ( $val, $msg ) = $principal->$method( Object => $obj, Right => $right ); + push( @results, $msg ); + } } return (@results); - - } +} # }}} @@ -1046,18 +1329,19 @@ Returns an array of success/failure messages sub UpdateRecordObject { my %args = ( - ARGSRef => undef, - AttributesRef => undef, - Object => undef, + ARGSRef => undef, + AttributesRef => undef, + Object => undef, AttributePrefix => undef, @_ ); - my $Object = $args{'Object'}; - my @results = $Object->Update(AttributesRef => $args{'AttributesRef'}, - ARGSRef => $args{'ARGSRef'}, - AttributePrefix => $args{'AttributePrefix'} - ); + my $Object = $args{'Object'}; + my @results = $Object->Update( + AttributesRef => $args{'AttributesRef'}, + ARGSRef => $args{'ARGSRef'}, + AttributePrefix => $args{'AttributePrefix'}, + ); return (@results); } @@ -1076,44 +1360,40 @@ sub ProcessCustomFieldUpdates { my $Object = $args{'CustomFieldObj'}; my $ARGSRef = $args{'ARGSRef'}; - my @attribs = qw( Name Type Description Queue SortOrder); + my @attribs = qw(Name Type Description Queue SortOrder); my @results = UpdateRecordObject( AttributesRef => \@attribs, Object => $Object, ARGSRef => $ARGSRef ); - if ( $ARGSRef->{ "CustomField-" . $Object->Id . "-AddValue-Name" } ) { - + my $prefix = "CustomField-" . $Object->Id; + if ( $ARGSRef->{"$prefix-AddValue-Name"} ) { my ( $addval, $addmsg ) = $Object->AddValue( - Name => - $ARGSRef->{ "CustomField-" . $Object->Id . "-AddValue-Name" }, - Description => $ARGSRef->{ "CustomField-" - . $Object->Id - . "-AddValue-Description" }, - SortOrder => $ARGSRef->{ "CustomField-" - . $Object->Id - . "-AddValue-SortOrder" }, + Name => $ARGSRef->{"$prefix-AddValue-Name"}, + Description => $ARGSRef->{"$prefix-AddValue-Description"}, + SortOrder => $ARGSRef->{"$prefix-AddValue-SortOrder"}, ); - push ( @results, $addmsg ); + push( @results, $addmsg ); } - my @delete_values = ( - ref $ARGSRef->{ 'CustomField-' . $Object->Id . '-DeleteValue' } eq - 'ARRAY' ) - ? @{ $ARGSRef->{ 'CustomField-' . $Object->Id . '-DeleteValue' } } - : ( $ARGSRef->{ 'CustomField-' . $Object->Id . '-DeleteValue' } ); + + my @delete_values + = ( ref $ARGSRef->{"$prefix-DeleteValue"} eq 'ARRAY' ) + ? @{ $ARGSRef->{"$prefix-DeleteValue"} } + : ( $ARGSRef->{"$prefix-DeleteValue"} ); + foreach my $id (@delete_values) { next unless defined $id; my ( $err, $msg ) = $Object->DeleteValue($id); - push ( @results, $msg ); + push( @results, $msg ); } my $vals = $Object->Values(); - while (my $cfv = $vals->Next()) { - if (my $so = $ARGSRef->{ 'CustomField-' . $Object->Id . '-SortOrder' . $cfv->Id }) { - if ($cfv->SortOrder != $so) { + while ( my $cfv = $vals->Next() ) { + if ( my $so = $ARGSRef->{ "$prefix-SortOrder" . $cfv->Id } ) { + if ( $cfv->SortOrder != $so ) { my ( $err, $msg ) = $cfv->SetSortOrder($so); - push ( @results, $msg ); + push( @results, $msg ); } } } @@ -1142,37 +1422,35 @@ sub ProcessTicketBasics { my $TicketObj = $args{'TicketObj'}; my $ARGSRef = $args{'ARGSRef'}; - # {{{ Set basic fields + # {{{ Set basic fields my @attribs = qw( - Subject - FinalPriority - Priority - TimeEstimated - TimeWorked - TimeLeft - Type - Status - Queue + Subject + FinalPriority + Priority + TimeEstimated + TimeWorked + TimeLeft + Type + Status + Queue ); - if ( $ARGSRef->{'Queue'} and ( $ARGSRef->{'Queue'} !~ /^(\d+)$/ ) ) { my $tempqueue = RT::Queue->new($RT::SystemUser); $tempqueue->Load( $ARGSRef->{'Queue'} ); if ( $tempqueue->id ) { - $ARGSRef->{'Queue'} = $tempqueue->Id(); + $ARGSRef->{'Queue'} = $tempqueue->id; } } + # Status isn't a field that can be set to a null value. + # RT core complains if you try + delete $ARGSRef->{'Status'} unless $ARGSRef->{'Status'}; - # Status isn't a field that can be set to a null value. - # RT core complains if you try - delete $ARGSRef->{'Status'} unless ($ARGSRef->{'Status'}); - my @results = UpdateRecordObject( AttributesRef => \@attribs, Object => $TicketObj, - ARGSRef => $ARGSRef + ARGSRef => $ARGSRef, ); # We special case owner changing, so we can use ForceOwnerChange @@ -1180,14 +1458,12 @@ sub ProcessTicketBasics { my ($ChownType); if ( $ARGSRef->{'ForceOwnerChange'} ) { $ChownType = "Force"; - } - else { + } else { $ChownType = "Give"; } - my ( $val, $msg ) = - $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType ); - push ( @results, $msg ); + my ( $val, $msg ) = $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType ); + push( @results, $msg ); } # }}} @@ -1205,58 +1481,59 @@ sub ProcessTicketCustomFieldUpdates { # Build up a list of objects that we want to work with my %custom_fields_to_mod; foreach my $arg ( keys %$ARGSRef ) { - if ( $arg =~ /^Ticket-(\d+-.*)/) { + if ( $arg =~ /^Ticket-(\d+-.*)/ ) { $ARGSRef->{"Object-RT::Ticket-$1"} = delete $ARGSRef->{$arg}; - } - elsif ( $arg =~ /^CustomField-(\d+-.*)/) { + } elsif ( $arg =~ /^CustomField-(\d+-.*)/ ) { $ARGSRef->{"Object-RT::Ticket--$1"} = delete $ARGSRef->{$arg}; } } - return ProcessObjectCustomFieldUpdates(%args, ARGSRef => $ARGSRef); + return ProcessObjectCustomFieldUpdates( %args, ARGSRef => $ARGSRef ); } sub ProcessObjectCustomFieldUpdates { - my %args = @_; + my %args = @_; my $ARGSRef = $args{'ARGSRef'}; my @results; # Build up a list of objects that we want to work with my %custom_fields_to_mod; foreach my $arg ( keys %$ARGSRef ) { + # format: Object-<object class>-<object id>-CustomField-<CF id>-<commands> next unless $arg =~ /^Object-([\w:]+)-(\d*)-CustomField-(\d+)-(.*)$/; # For each of those objects, find out what custom fields we want to work with. - $custom_fields_to_mod{ $1 }{ $2 || 0 }{ $3 }{ $4 } = $ARGSRef->{ $arg }; + $custom_fields_to_mod{$1}{ $2 || 0 }{$3}{$4} = $ARGSRef->{$arg}; } # For each of those objects foreach my $class ( keys %custom_fields_to_mod ) { - foreach my $id ( keys %{$custom_fields_to_mod{$class}} ) { + foreach my $id ( keys %{ $custom_fields_to_mod{$class} } ) { my $Object = $args{'Object'}; $Object = $class->new( $session{'CurrentUser'} ) unless $Object && ref $Object eq $class; - $Object->Load( $id ) unless ($Object->id || 0) == $id; + $Object->Load($id) unless ( $Object->id || 0 ) == $id; unless ( $Object->id ) { $RT::Logger->warning("Couldn't load object $class #$id"); next; } - foreach my $cf ( keys %{ $custom_fields_to_mod{ $class }{ $id } } ) { + foreach my $cf ( keys %{ $custom_fields_to_mod{$class}{$id} } ) { my $CustomFieldObj = RT::CustomField->new( $session{'CurrentUser'} ); - $CustomFieldObj->LoadById( $cf ); + $CustomFieldObj->LoadById($cf); unless ( $CustomFieldObj->id ) { - $RT::Logger->warning("Couldn't load custom field #$id"); + $RT::Logger->warning("Couldn't load custom field #$cf"); next; } - push @results, _ProcessObjectCustomFieldUpdates( + push @results, + _ProcessObjectCustomFieldUpdates( Prefix => "Object-$class-$id-CustomField-$cf-", Object => $Object, CustomField => $CustomFieldObj, ARGS => $custom_fields_to_mod{$class}{$id}{$cf}, - ); + ); } } } @@ -1264,20 +1541,32 @@ sub ProcessObjectCustomFieldUpdates { } sub _ProcessObjectCustomFieldUpdates { - my %args = @_; - my $cf = $args{'CustomField'}; + my %args = @_; + my $cf = $args{'CustomField'}; my $cf_type = $cf->Type; + # Remove blank Values since the magic field will take care of this. Sometimes + # the browser gives you a blank value which causes CFs to be processed twice + if ( defined $args{'ARGS'}->{'Values'} + && !length $args{'ARGS'}->{'Values'} + && $args{'ARGS'}->{'Values-Magic'} ) + { + delete $args{'ARGS'}->{'Values'}; + } + my @results; foreach my $arg ( keys %{ $args{'ARGS'} } ) { - - next if $arg =~ /Category$/; + + # skip category argument + next if $arg eq 'Category'; # since http won't pass in a form element with a null value, we need # to fake it if ( $arg eq 'Values-Magic' ) { + # We don't care about the magic, if there's really a values element; - next if $args{'ARGS'}->{'Value'} || $args{'ARGS'}->{'Values'}; + next if defined $args{'ARGS'}->{'Value'} && length $args{'ARGS'}->{'Value'}; + next if defined $args{'ARGS'}->{'Values'} && length $args{'ARGS'}->{'Values'}; # "Empty" values does not mean anything for Image and Binary fields next if $cf_type =~ /^(?:Image|Binary)$/; @@ -1287,18 +1576,21 @@ sub _ProcessObjectCustomFieldUpdates { } my @values = (); - if ( ref $args{'ARGS'}->{ $arg } eq 'ARRAY' ) { + if ( ref $args{'ARGS'}->{$arg} eq 'ARRAY' ) { @values = @{ $args{'ARGS'}->{$arg} }; - } elsif ( $cf_type =~ /text/i ) { # Both Text and Wikitext - @values = ($args{'ARGS'}->{$arg}); - } elsif ( defined( $args{'ARGS'}->{ $arg } ) ) { - @values = split /\n/, $args{'ARGS'}->{ $arg }; - } - - if ( ( $cf_type eq 'Freeform' && !$cf->SingleValue ) || $cf_type =~ /text/i ) { - s/\r//g foreach @values; + } elsif ( $cf_type =~ /text/i ) { # Both Text and Wikitext + @values = ( $args{'ARGS'}->{$arg} ); + } else { + @values = split /\r*\n/, $args{'ARGS'}->{$arg} + if defined $args{'ARGS'}->{$arg}; } - @values = grep defined && $_ ne '', @values; + @values = grep length, map { + s/\r+\n/\n/g; + s/^\s+//; + s/\s+$//; + $_; + } + grep defined, @values; if ( $arg eq 'AddValue' || $arg eq 'Value' ) { foreach my $value (@values) { @@ -1306,69 +1598,63 @@ sub _ProcessObjectCustomFieldUpdates { Field => $cf->id, Value => $value ); - push ( @results, $msg ); + push( @results, $msg ); } - } - elsif ( $arg eq 'Upload' ) { + } elsif ( $arg eq 'Upload' ) { my $value_hash = _UploadedFile( $args{'Prefix'} . $arg ) or next; - my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( - %$value_hash, - Field => $cf, - ); - push ( @results, $msg ); - } - elsif ( $arg eq 'DeleteValues' ) { - foreach my $value ( @values ) { + my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( %$value_hash, Field => $cf, ); + push( @results, $msg ); + } elsif ( $arg eq 'DeleteValues' ) { + foreach my $value (@values) { my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue( Field => $cf, Value => $value, ); - push ( @results, $msg ); + push( @results, $msg ); } - } - elsif ( $arg eq 'DeleteValueIds' ) { - foreach my $value ( @values ) { + } elsif ( $arg eq 'DeleteValueIds' ) { + foreach my $value (@values) { my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue( Field => $cf, ValueId => $value, ); - push ( @results, $msg ); + push( @results, $msg ); } - } - elsif ( $arg eq 'Values' && !$cf->Repeated ) { + } elsif ( $arg eq 'Values' && !$cf->Repeated ) { my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id ); my %values_hash; - foreach my $value ( @values ) { - # build up a hash of values that the new set has - $values_hash{$value} = 1; - next if $cf_values->HasEntry( $value ); + foreach my $value (@values) { + if ( my $entry = $cf_values->HasEntry($value) ) { + $values_hash{ $entry->id } = 1; + next; + } my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( Field => $cf, Value => $value ); - push ( @results, $msg ); + push( @results, $msg ); + $values_hash{$val} = 1 if $val; } $cf_values->RedoSearch; while ( my $cf_value = $cf_values->Next ) { - next if $values_hash{ $cf_value->Content }; + next if $values_hash{ $cf_value->id }; my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue( - Field => $cf, - Value => $cf_value->Content + Field => $cf, + ValueId => $cf_value->id ); - push ( @results, $msg); + push( @results, $msg ); } - } - elsif ( $arg eq 'Values' ) { + } elsif ( $arg eq 'Values' ) { my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id ); # keep everything up to the point of difference, delete the rest my $delete_flag; - foreach my $old_cf (@{$cf_values->ItemsArrayRef}) { - if (!$delete_flag and @values and $old_cf->Content eq $values[0]) { + foreach my $old_cf ( @{ $cf_values->ItemsArrayRef } ) { + if ( !$delete_flag and @values and $old_cf->Content eq $values[0] ) { shift @values; next; } @@ -1378,18 +1664,20 @@ sub _ProcessObjectCustomFieldUpdates { } # now add/replace extra things, if any - foreach my $value ( @values ) { + foreach my $value (@values) { my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( Field => $cf, Value => $value ); - push ( @results, $msg ); + push( @results, $msg ); } - } - else { - push ( @results, + } else { + push( + @results, loc("User asked for an unknown update type for custom field [_1] for [_2] object #[_3]", - $cf->Name, ref $args{'Object'}, $args{'Object'}->id ) + $cf->Name, ref $args{'Object'}, + $args{'Object'}->id + ) ); } } @@ -1420,8 +1708,7 @@ sub ProcessTicketWatchers { foreach my $key ( keys %$ARGSRef ) { # Delete deletable watchers - if ( ( $key =~ /^Ticket-DeleteWatcher-Type-(.*)-Principal-(\d+)$/ ) ) - { + if ( $key =~ /^Ticket-DeleteWatcher-Type-(.*)-Principal-(\d+)$/ ) { my ( $code, $msg ) = $Ticket->DeleteWatcher( PrincipalId => $2, Type => $1 @@ -1439,8 +1726,8 @@ sub ProcessTicketWatchers { } # Add new wathchers by email address - elsif ( ( $ARGSRef->{$key} =~ /^(AdminCc|Cc|Requestor)$/ ) - and ( $key =~ /^WatcherTypeEmail(\d*)$/ ) ) + elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor)$/ + and $key =~ /^WatcherTypeEmail(\d*)$/ ) { #They're in this order because otherwise $1 gets clobbered :/ @@ -1463,7 +1750,7 @@ sub ProcessTicketWatchers { # Add new watchers by owner elsif ( $key =~ /^Ticket-AddWatcher-Principal-(\d*)$/ ) { my $principal_id = $1; - my $form = $ARGSRef->{$key}; + my $form = $ARGSRef->{$key}; foreach my $value ( ref($form) ? @{$form} : ($form) ) { next unless $value =~ /^(?:AdminCc|Cc|Requestor)$/i; @@ -1503,33 +1790,33 @@ sub ProcessTicketDates { # {{{ Set date fields my @date_fields = qw( - Told - Resolved - Starts - Started - Due + Told + Resolved + Starts + Started + Due ); #Run through each field in this list. update the value if apropriate foreach my $field (@date_fields) { + next unless exists $ARGSRef->{ $field . '_Date' }; + next if $ARGSRef->{ $field . '_Date' } eq ''; + my ( $code, $msg ); my $DateObj = RT::Date->new( $session{'CurrentUser'} ); + $DateObj->Set( + Format => 'unknown', + Value => $ARGSRef->{ $field . '_Date' } + ); - #If it's something other than just whitespace - if ( $ARGSRef->{ $field . '_Date' } && ($ARGSRef->{ $field . '_Date' } ne '') ) { - $DateObj->Set( - Format => 'unknown', - Value => $ARGSRef->{ $field . '_Date' } - ); - my $obj = $field . "Obj"; - if ( ( defined $DateObj->Unix ) - and ( $DateObj->Unix ne $Ticket->$obj()->Unix() ) ) - { - my $method = "Set$field"; - my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO ); - push @results, "$msg"; - } + my $obj = $field . "Obj"; + if ( ( defined $DateObj->Unix ) + and ( $DateObj->Unix != $Ticket->$obj()->Unix() ) ) + { + my $method = "Set$field"; + my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO ); + push @results, "$msg"; } } @@ -1548,21 +1835,21 @@ Returns an array of results messages. =cut sub ProcessTicketLinks { - my %args = ( TicketObj => undef, - ARGSRef => undef, - @_ ); + my %args = ( + TicketObj => undef, + ARGSRef => undef, + @_ + ); my $Ticket = $args{'TicketObj'}; my $ARGSRef = $args{'ARGSRef'}; - - my (@results) = ProcessRecordLinks(RecordObj => $Ticket, - ARGSRef => $ARGSRef); + my (@results) = ProcessRecordLinks( RecordObj => $Ticket, ARGSRef => $ARGSRef ); #Merge if we need to if ( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ) { - my ( $val, $msg ) = - $Ticket->MergeInto( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ); + $ARGSRef->{ $Ticket->Id . "-MergeInto" } =~ s/\s+//g; + my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ); push @results, $msg; } @@ -1572,9 +1859,11 @@ sub ProcessTicketLinks { # }}} sub ProcessRecordLinks { - my %args = ( RecordObj => undef, - ARGSRef => undef, - @_ ); + my %args = ( + RecordObj => undef, + ARGSRef => undef, + @_ + ); my $Record = $args{'RecordObj'}; my $ARGSRef = $args{'ARGSRef'}; @@ -1588,11 +1877,11 @@ sub ProcessRecordLinks { my $type = $2; my $target = $3; - push @results, - "Trying to delete: Base: $base Target: $target Type $type"; - my ( $val, $msg ) = $Record->DeleteLink( Base => $base, - Type => $type, - Target => $target ); + my ( $val, $msg ) = $Record->DeleteLink( + Base => $base, + Type => $type, + Target => $target + ); push @results, $msg; @@ -1604,28 +1893,38 @@ sub ProcessRecordLinks { foreach my $linktype (@linktypes) { if ( $ARGSRef->{ $Record->Id . "-$linktype" } ) { - for my $luri ( split ( / /, $ARGSRef->{ $Record->Id . "-$linktype" } ) ) { - $luri =~ s/\s*$//; # Strip trailing whitespace - my ( $val, $msg ) = $Record->AddLink( Target => $luri, - Type => $linktype ); + $ARGSRef->{ $Record->Id . "-$linktype" } = join( ' ', @{ $ARGSRef->{ $Record->Id . "-$linktype" } } ) + if ref( $ARGSRef->{ $Record->Id . "-$linktype" } ); + + for my $luri ( split( / /, $ARGSRef->{ $Record->Id . "-$linktype" } ) ) { + next unless $luri; + $luri =~ s/\s+$//; # Strip trailing whitespace + my ( $val, $msg ) = $Record->AddLink( + Target => $luri, + Type => $linktype + ); push @results, $msg; } } if ( $ARGSRef->{ "$linktype-" . $Record->Id } ) { - - for my $luri ( split ( / /, $ARGSRef->{ "$linktype-" . $Record->Id } ) ) { - my ( $val, $msg ) = $Record->AddLink( Base => $luri, - Type => $linktype ); + $ARGSRef->{ "$linktype-" . $Record->Id } = join( ' ', @{ $ARGSRef->{ "$linktype-" . $Record->Id } } ) + if ref( $ARGSRef->{ "$linktype-" . $Record->Id } ); + + for my $luri ( split( / /, $ARGSRef->{ "$linktype-" . $Record->Id } ) ) { + next unless $luri; + my ( $val, $msg ) = $Record->AddLink( + Base => $luri, + Type => $linktype + ); push @results, $msg; } - } + } } return (@results); } - =head2 _UploadedFile ( $arg ); Takes a CGI parameter name; if a file is uploaded under that name, @@ -1637,9 +1936,9 @@ Returns C<undef> if no files were uploaded in the C<$arg> field. =cut sub _UploadedFile { - my $arg = shift; - my $cgi_object = $m->cgi_object; - my $fh = $cgi_object->upload($arg) or return undef; + my $arg = shift; + my $cgi_object = $m->cgi_object; + my $fh = $cgi_object->upload($arg) or return undef; my $upload_info = $cgi_object->uploadInfo($fh); my $filename = "$fh"; @@ -1647,12 +1946,50 @@ sub _UploadedFile { binmode($fh); return { - Value => $filename, + Value => $filename, LargeContent => do { local $/; scalar <$fh> }, - ContentType => $upload_info->{'Content-Type'}, + ContentType => $upload_info->{'Content-Type'}, }; } +sub GetColumnMapEntry { + my %args = ( Map => {}, Name => '', Attribute => undef, @_ ); + + # deal with the simplest thing first + if ( $args{'Map'}{ $args{'Name'} } ) { + return $args{'Map'}{ $args{'Name'} }{ $args{'Attribute'} }; + } + + # complex things + elsif ( my ( $mainkey, $subkey ) = $args{'Name'} =~ /^(.*?)\.{(.+)}$/ ) { + return undef unless $args{'Map'}->{$mainkey}; + return $args{'Map'}{$mainkey}{ $args{'Attribute'} } + unless ref $args{'Map'}{$mainkey}{ $args{'Attribute'} } eq 'CODE'; + + return sub { $args{'Map'}{$mainkey}{ $args{'Attribute'} }->( @_, $subkey ) }; + } + return undef; +} + +sub ProcessColumnMapValue { + my $value = shift; + my %args = ( Arguments => [], Escape => 1, @_ ); + + if ( ref $value ) { + if ( UNIVERSAL::isa( $value, 'CODE' ) ) { + my @tmp = $value->( @{ $args{'Arguments'} } ); + return ProcessColumnMapValue( ( @tmp > 1 ? \@tmp : $tmp[0] ), %args ); + } elsif ( UNIVERSAL::isa( $value, 'ARRAY' ) ) { + return join '', map ProcessColumnMapValue( $_, %args ), @$value; + } elsif ( UNIVERSAL::isa( $value, 'SCALAR' ) ) { + return $$value; + } + } + + return $m->interp->apply_escapes( $value, 'h' ) if $args{'Escape'}; + return $value; +} + =head2 _load_container_object ( $type, $id ); Instantiate container object for saving searches. @@ -1660,8 +1997,8 @@ Instantiate container object for saving searches. =cut sub _load_container_object { - my ($obj_type, $obj_id) = @_; - return RT::SavedSearch->new($session{'CurrentUser'})->_load_privacy_object($obj_type, $obj_id); + my ( $obj_type, $obj_id ) = @_; + return RT::SavedSearch->new( $session{'CurrentUser'} )->_load_privacy_object( $obj_type, $obj_id ); } =head2 _parse_saved_search ( $arg ); @@ -1674,19 +2011,19 @@ container object and the search id. sub _parse_saved_search { my $spec = shift; return unless $spec; - if ($spec !~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) { + if ( $spec !~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) { return; } my $obj_type = $1; my $obj_id = $2; my $search_id = $3; - return (_load_container_object ($obj_type, $obj_id), $search_id); + return ( _load_container_object( $obj_type, $obj_id ), $search_id ); } eval "require RT::Interface::Web_Vendor"; -die $@ if ($@ && $@ !~ qr{^Can't locate RT/Interface/Web_Vendor.pm}); +die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Web_Vendor.pm} ); eval "require RT::Interface::Web_Local"; -die $@ if ($@ && $@ !~ qr{^Can't locate RT/Interface/Web_Local.pm}); +die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Web_Local.pm} ); 1; diff --git a/rt/lib/RT/Interface/Web/Handler.pm b/rt/lib/RT/Interface/Web/Handler.pm index 260a3b319..8d17921cb 100644 --- a/rt/lib/RT/Interface/Web/Handler.pm +++ b/rt/lib/RT/Interface/Web/Handler.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Web::Handler; use CGI qw/-private_tempfiles/; @@ -56,6 +57,7 @@ use Time::HiRes; use HTML::Entities; use HTML::Scrubber; use RT::Interface::Web::Handler; +use RT::Interface::Web::Request; use File::Path qw( rmtree ); use File::Glob qw( bsd_glob ); use File::Spec::Unix; @@ -63,15 +65,19 @@ use File::Spec::Unix; sub DefaultHandlerArgs { ( comp_root => [ [ local => $RT::MasonLocalComponentRoot ], + (map {[ "plugin-".$_->Name => $_->ComponentRoot ]} @{RT->Plugins}), [ standard => $RT::MasonComponentRoot ] ], default_escape_flags => 'h', data_dir => "$RT::MasonDataDir", allow_globals => [qw(%session)], # Turn off static source if we're in developer mode. - static_source => ($RT::DevelMode ? '0' : '1'), - use_object_files => ($RT::DevelMode ? '0' : '1'), - autoflush => 0 + static_source => (RT->Config->Get('DevelMode') ? '0' : '1'), + use_object_files => (RT->Config->Get('DevelMode') ? '0' : '1'), + autoflush => 0, + error_format => (RT->Config->Get('DevelMode') ? 'html': 'brief'), + request_class => 'RT::Interface::Web::Request', + named_component_subs => $INC{'Devel/Cover.pm'} ? 1 : 0, ) }; # {{{ sub new @@ -87,10 +93,7 @@ sub new { my $class = shift; $class->InitSessionDir; - if ( $mod_perl::VERSION && $mod_perl::VERSION >= 1.9908 ) { - goto &NewApacheHandler; - } - elsif ($CGI::MOD_PERL) { + if ( ($mod_perl::VERSION && $mod_perl::VERSION >= 1.9908) || $CGI::MOD_PERL) { goto &NewApacheHandler; } else { @@ -102,16 +105,17 @@ sub InitSessionDir { # Activate the following if running httpd as root (the normal case). # Resets ownership of all files created by Mason at startup. # Note that mysql uses DB for sessions, so there's no need to do this. - unless ( $RT::DatabaseType =~ /(?:mysql|Pg)/ ) { + unless ( RT->Config->Get('DatabaseType') =~ /(?:mysql|Pg)/ ) { # Clean up our umask to protect session files umask(0077); - if ($CGI::MOD_PERL) { local $@; eval { + if ($CGI::MOD_PERL and $CGI::MOD_PERL < 1.9908 ) { chown( Apache->server->uid, Apache->server->gid, $RT::MasonSessionDir ) - }} + if Apache->server->can('uid'); + } # Die if WebSessionDir doesn't exist or we can't write to it stat($RT::MasonSessionDir); @@ -139,22 +143,6 @@ sub NewApacheHandler { # }}} -# {{{ sub NewApache2Handler - -=head2 NewApache2Handler - - Takes extra options to pass to MasonX::Apache2Handler->new - Returns a new MasonX::Apache2Handler object - -=cut - -sub NewApache2Handler { - require MasonX::Apache2Handler; - return NewHandler('MasonX::Apache2Handler', args_method => "CGI", @_); -} - -# }}} - # {{{ sub NewCGIHandler =head2 NewCGIHandler @@ -182,15 +170,30 @@ sub NewHandler { =head2 CleanupRequest -Rollback any uncommitted transaction. -Flush the ACL cache -Flush the searchbuilder query cache +Clean ups globals, caches and other things that could be still +there from previous requests: + +=over 4 + +=item Rollback any uncommitted transaction(s) + +=item Flush the ACL cache + +=item Flush records cache of the L<DBIx::SearchBuilder> if +WebFlushDbCacheEveryRequest option is enabled, what is true by default +and is not recommended to change. + +=item Clean up state of RT::Action::SendEmail using 'CleanSlate' method + +=item Flush tmp GnuPG key preferences + +=back =cut sub CleanupRequest { - if ( $RT::Handle->TransactionDepth ) { + if ( $RT::Handle && $RT::Handle->TransactionDepth ) { $RT::Handle->ForceRollback; $RT::Logger->crit( "Transaction not committed. Usually indicates a software fault." @@ -201,10 +204,19 @@ sub CleanupRequest { # Consistency is imprived, too. RT::Principal->InvalidateACLCache(); DBIx::SearchBuilder::Record::Cachable->FlushCache - if ( $RT::WebFlushDbCacheEveryRequest + if ( RT->Config->Get('WebFlushDbCacheEveryRequest') and UNIVERSAL::can( 'DBIx::SearchBuilder::Record::Cachable' => 'FlushCache' ) ); + # cleanup global squelching of the mails + require RT::Action::SendEmail; + RT::Action::SendEmail->CleanSlate; + + if (RT->Config->Get('GnuPG')->{'Enable'}) { + require RT::Crypt::GnuPG; + RT::Crypt::GnuPG::UseKeyForEncryption(); + RT::Crypt::GnuPG::UseKeyForSigning( undef ); + } } # }}} diff --git a/rt/lib/RT/Interface/Web/Menu.pm b/rt/lib/RT/Interface/Web/Menu.pm index de479dad6..35699429e 100644 --- a/rt/lib/RT/Interface/Web/Menu.pm +++ b/rt/lib/RT/Interface/Web/Menu.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Web::Menu; diff --git a/rt/lib/RT/Interface/Web/Menu/Item.pm b/rt/lib/RT/Interface/Web/Menu/Item.pm index 4149a0b1c..8eb4120c6 100644 --- a/rt/lib/RT/Interface/Web/Menu/Item.pm +++ b/rt/lib/RT/Interface/Web/Menu/Item.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Web::Menu::Item; diff --git a/rt/lib/RT/Interface/Web/QueryBuilder.pm b/rt/lib/RT/Interface/Web/QueryBuilder.pm index f93c4159f..29d12b464 100755 --- a/rt/lib/RT/Interface/Web/QueryBuilder.pm +++ b/rt/lib/RT/Interface/Web/QueryBuilder.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,6 +45,7 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Web::QueryBuilder; use strict; diff --git a/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm b/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm index b60520603..574ead465 100755 --- a/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm +++ b/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,11 +45,13 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Web::QueryBuilder::Tree; use strict; use warnings; +use Tree::Simple qw/use_weak_refs/; use base qw/Tree::Simple/; =head1 NAME @@ -77,13 +79,15 @@ on the root node passed to it.) sub TraversePrePost { my ($self, $prefunc, $postfunc) = @_; - $prefunc->($self); - + # XXX: if pre or post action changes siblings (delete or adds) + # we could have problems + $prefunc->($self) if $prefunc; + foreach my $child ($self->getAllChildren()) { $child->TraversePrePost($prefunc, $postfunc); } - $postfunc->($self); + $postfunc->($self) if $postfunc; } =head2 GetReferencedQueues @@ -103,10 +107,11 @@ sub GetReferencedQueues { my $node = shift; return if $node->isRoot; + return unless $node->isLeaf; my $clause = $node->getNodeValue(); - - if ( ref($clause) and $clause->{Key} eq 'Queue' ) { + + if ( $clause->{Key} eq 'Queue' ) { $queues->{ $clause->{Value} } = 1; }; } @@ -133,55 +138,13 @@ sub GetQueryAndOptionList { my $self = shift; my $selected_nodes = shift; - my $optionlist = []; - - my $i = 0; - - $self->TraversePrePost( - sub { # This is called before recursing to the node's children. - my $node = shift; - - return if $node->isRoot or $node->getParent->isRoot; - - my $clause = $node->getNodeValue(); - my $str = ' '; - my $aggregator_context = $node->getParent()->getNodeValue(); - $str = $aggregator_context . " " if $node->getIndex() > 0; - - if ( ref($clause) ) { # ie, it's a leaf - $str .= - $clause->{Key} . " " . $clause->{Op} . " " . $clause->{Value}; - } - - unless ($node->getParent->getParent->isRoot) { - # used to check !ref( $parent->getNodeValue() ) ) - if ( $node->getIndex() == 0 ) { - $str = '( ' . $str; - } - } - - push @$optionlist, { - TEXT => $str, - INDEX => $i, - SELECTED => (grep { $_ == $node } @$selected_nodes) ? 'SELECTED' : '', - DEPTH => $node->getDepth() - 1, - }; + my $list = $self->__LinearizeTree; + foreach my $e( @$list ) { + $e->{'DEPTH'} = $e->{'NODE'}->getDepth; + $e->{'SELECTED'} = (grep $_ == $e->{'NODE'}, @$selected_nodes)? qq[ selected="selected"] : ''; + } - $i++; - }, sub { - # This is called after recursing to the node's children. - my $node = shift; - - return if $node->isRoot or $node->getParent->isRoot or $node->getParent->getParent->isRoot; - - # Only do this for the rightmost child. - return unless $node->getIndex == $node->getParent->getChildCount - 1; - - $optionlist->[-1]{TEXT} .= ' )'; - } - ); - - return (join ' ', map { $_->{TEXT} } @$optionlist), $optionlist; + return (join ' ', map $_->{'TEXT'}, @$list), $list; } =head2 PruneChildLessAggregators @@ -195,23 +158,18 @@ sub PruneChildlessAggregators { my $self = shift; $self->TraversePrePost( - sub { - }, + undef, sub { my $node = shift; + return unless $node->isLeaf; - return if $node->isRoot or $node->getParent->isRoot; - # We're only looking for aggregators (AND/OR) return if ref $node->getNodeValue; - - return if $node->getChildCount != 0; - + + return if $node->isRoot; + # OK, this is a childless aggregator. Remove self. - $node->getParent->removeChild($node); - - # Deal with circular refs $node->DESTROY; } ); @@ -226,18 +184,102 @@ In fact, it's all of them but the root and its child. =cut sub GetDisplayedNodes { + return map $_->{NODE}, @{ (shift)->__LinearizeTree }; +} + + +sub __LinearizeTree { my $self = shift; - my @lines; - $self->traverse(sub { + my ($list, $i) = ([], 0); + + $self->TraversePrePost( sub { my $node = shift; + return if $node->isRoot; + + my $str = ''; + if( $node->getIndex > 0 ) { + $str .= " ". $node->getParent->getNodeValue ." "; + } + + unless( $node->isLeaf ) { + $str .= '( '; + } else { + + my $clause = $node->getNodeValue; + $str .= $clause->{Key}; + $str .= " ". $clause->{Op}; + $str .= " ". $clause->{Value}; - push @lines, $node unless $node->isRoot or $node->getParent->isRoot; + } + $str =~ s/^\s+|\s+$//; + + push @$list, { + NODE => $node, + TEXT => $str, + INDEX => $i, + }; + + $i++; + }, sub { + my $node = shift; + return if $node->isRoot; + return if $node->isLeaf; + $list->[-1]->{'TEXT'} .= ' )'; }); - return @lines; + return $list; } +sub ParseSQL { + my $self = shift; + my %args = ( + Query => '', + CurrentUser => '', #XXX: Hack + @_ + ); + my $string = $args{'Query'}; + + my @results; + + my %field = %{ RT::Tickets->new( $args{'CurrentUser'} )->FIELDS }; + my %lcfield = map { ( lc($_) => $_ ) } keys %field; + + my $node = $self; + + my %callback; + $callback{'OpenParen'} = sub { + $node = __PACKAGE__->new( 'AND', $node ); + }; + $callback{'CloseParen'} = sub { $node = $node->getParent }; + $callback{'EntryAggregator'} = sub { $node->setNodeValue( $_[0] ) }; + $callback{'Condition'} = sub { + my ($key, $op, $value) = @_; + + my ($main_key) = split /[.]/, $key; + + my $class; + if ( exists $lcfield{ lc $main_key } ) { + $class = $field{ $main_key }->[0]; + $key =~ s/^[^.]+/ $lcfield{ lc $main_key } /e; + } + unless( $class ) { + push @results, [ $args{'CurrentUser'}->loc("Unknown field: [_1]", $key), -1 ] + } + + $value =~ s/'/\\'/g; + $value = "'$value'" if $value =~ /[^0-9]/; + $key = "'$key'" if $key =~ /^CF./; + + my $clause = { Key => $key, Op => $op, Value => $value }; + $node->addChild( __PACKAGE__->new( $clause ) ); + }; + $callback{'Error'} = sub { push @results, @_ }; + + require RT::SQL; + RT::SQL::Parse($string, \%callback); + return @results; +} eval "require RT::Interface::Web::QueryBuilder::Tree_Vendor"; die $@ if ($@ && $@ !~ qr{^Can't locate RT/Interface/Web/QueryBuilder/Tree_Vendor.pm}); diff --git a/rt/lib/RT/Interface/Web/Request.pm b/rt/lib/RT/Interface/Web/Request.pm new file mode 100644 index 000000000..e1794640d --- /dev/null +++ b/rt/lib/RT/Interface/Web/Request.pm @@ -0,0 +1,207 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# <jesse@bestpractical.com> +# +# (Except where explicitly superseded by other copyright notices) +# +# +# LICENSE: +# +# This work is made available to you under the terms of Version 2 of +# the GNU General Public License. A copy of that license should have +# been provided with this software, but in any event can be snarfed +# from www.gnu.org. +# +# This work 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. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 or visit their web page on the internet at +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. +# +# +# CONTRIBUTION SUBMISSION POLICY: +# +# (The following paragraph is not intended to limit the rights granted +# to you to modify and distribute this software under the terms of +# the GNU General Public License and is only of importance to you if +# you choose to contribute your changes and enhancements to the +# community by submitting them to Best Practical Solutions, LLC.) +# +# By intentionally submitting any modifications, corrections or +# derivatives to this work, or any other work intended for use with +# Request Tracker, to Best Practical Solutions, LLC, you confirm that +# you are the copyright holder for those contributions and you grant +# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, +# royalty-free, perpetual, license to use, copy, create derivative +# works based on those contributions, and sublicense and distribute +# those contributions and any derivatives thereof. +# +# END BPS TAGGED BLOCK }}} + +package RT::Interface::Web::Request; + +use strict; +use warnings; + +our $VERSION = '0.30'; +use base qw(HTML::Mason::Request); + +sub new { + my $class = shift; + + my $new_class = $HTML::Mason::ApacheHandler::VERSION ? + 'HTML::Mason::Request::ApacheHandler' : + $HTML::Mason::CGIHandler::VERSION ? + 'HTML::Mason::Request::CGI' : + 'HTML::Mason::Request'; + + $class->alter_superclass( $new_class ); + $class->valid_params( %{ $new_class->valid_params } ); + return $class->SUPER::new(@_); +} + +# XXX TODO: This alter_superclass replaces teh funcitonality in Mason 1.39 +# with code which doesn't trigger a bug in Perl 5.10. +# (Perl 5.10.0 does NOT take kindly to having its @INC entries changed) +# http://rt.perl.org/rt3/Public/Bug/Display.html?id=54566 +# +# This routine can be removed when either: +# * RT depends on a version of mason which contains this fix +# * Perl 5.10.0 is not supported for running RT +sub alter_superclass { + my $class = shift; + my $new_super = shift; + my $isa_ref; + { no strict 'refs'; my @entries = @{$class."::ISA"}; $isa_ref = \@entries; } + + # handles multiple inheritance properly and preserve + # inheritance order + for ( my $x = 0; $x <= $#{$isa_ref} ; $x++ ) { + if ( $isa_ref->[$x]->isa('HTML::Mason::Request') ) { + my $old_super = $isa_ref->[$x]; + $isa_ref->[$x] = $new_super + if ( $old_super ne $new_super ); + last; + } + } + + { no strict 'refs'; @{$class."::ISA"} = @$isa_ref; } + $class->valid_params( %{ $class->valid_params } ); +} + + +=head2 callback + +Method replaces deprecated component C<Element/Callback>. + +Takes hash with optional C<CallbackPage>, C<CallbackName> +and C<CallbackOnce> arguments, other arguments are passed +throught to callback components. + +=over 4 + +=item CallbackPage + +Page path relative to the root, leading slash is mandatory. +By default is equal to path of the caller component. + +=item CallbackName + +Name of the callback. C<Default> is used unless specified. + +=item CallbackOnce + +By default is false, otherwise runs callbacks only once per +process of the server. Such callbacks can be used to fill +structures. + +=back + +Searches for callback components in +F<< /Callbacks/<any dir>/CallbackPage/CallbackName >>, for +example F</Callbacks/MyExtension/autohandler/Default> would +be called as default callback for F</autohandler>. + +=cut + +{ +my %cache = (); +my %called = (); +sub callback { + my ($self, %args) = @_; + + my $name = delete $args{'CallbackName'} || 'Default'; + my $page = delete $args{'CallbackPage'} || $self->callers(0)->path; + unless ( $page ) { + $RT::Logger->error("Couldn't get a page name for callbacks"); + return; + } + + my $CacheKey = "$page--$name"; + return 1 if delete $args{'CallbackOnce'} && $called{ $CacheKey }; + $called{ $CacheKey } = 1; + + my $callbacks = $cache{ $CacheKey }; + unless ( $callbacks ) { + $callbacks = []; + my $path = "/Callbacks/*$page/$name"; + my @roots = map $_->[1], + $HTML::Mason::VERSION <= 1.28 + ? $self->interp->resolver->comp_root_array + : $self->interp->comp_root_array; + + my %seen; + @$callbacks = ( + sort grep defined && length, + # Skip backup files, files without a leading package name, + # and files we've already seen + grep !$seen{$_}++ && !m{/\.} && !m{~$} && m{^/Callbacks/[^/]+\Q$page/$name\E$}, + map $self->interp->resolver->glob_path($path, $_), + @roots + ); + foreach my $comp (keys %seen) { + next unless $seen{$comp} > 1; + $RT::Logger->error("Found more than one occurrence of the $comp callback. This may cause only one of the callbacks to run. Look for the duplicate Callback in your @roots"); + } + + $cache{ $CacheKey } = $callbacks unless RT->Config->Get('DevelMode'); + } + + my @rv; + foreach my $cb ( @$callbacks ) { + push @rv, scalar $self->comp( $cb, %args ); + } + return @rv; +} +} + +=head2 request_path + +Returns path of the request. + +Very close to C<< $m->request_comp->path >>, but if called in a dhandler returns +path of the request without dhandler name, but with dhandler arguments instead. + +=cut + +sub request_path { + my $self = shift; + + my $path = $self->request_comp->path; + # disabled dhandlers, not RT case, but anyway + return $path unless my $dh_name = $self->dhandler_name; + # not a dhandler + return $path unless substr($path, -length("/$dh_name")) eq "/$dh_name"; + substr($path, -length $dh_name) = $self->dhandler_arg; + return $path; +} + +1; diff --git a/rt/lib/RT/Interface/Web/Session.pm b/rt/lib/RT/Interface/Web/Session.pm new file mode 100644 index 000000000..4998c34f9 --- /dev/null +++ b/rt/lib/RT/Interface/Web/Session.pm @@ -0,0 +1,285 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# <jesse@bestpractical.com> +# +# (Except where explicitly superseded by other copyright notices) +# +# +# LICENSE: +# +# This work is made available to you under the terms of Version 2 of +# the GNU General Public License. A copy of that license should have +# been provided with this software, but in any event can be snarfed +# from www.gnu.org. +# +# This work 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. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 or visit their web page on the internet at +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. +# +# +# CONTRIBUTION SUBMISSION POLICY: +# +# (The following paragraph is not intended to limit the rights granted +# to you to modify and distribute this software under the terms of +# the GNU General Public License and is only of importance to you if +# you choose to contribute your changes and enhancements to the +# community by submitting them to Best Practical Solutions, LLC.) +# +# By intentionally submitting any modifications, corrections or +# derivatives to this work, or any other work intended for use with +# Request Tracker, to Best Practical Solutions, LLC, you confirm that +# you are the copyright holder for those contributions and you grant +# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, +# royalty-free, perpetual, license to use, copy, create derivative +# works based on those contributions, and sublicense and distribute +# those contributions and any derivatives thereof. +# +# END BPS TAGGED BLOCK }}} + +package RT::Interface::Web::Session; +use warnings; +use strict; + +use RT::CurrentUser; + +=head1 NAME + +RT::Interface::Web::Session - RT web session class + +=head1 SYNOPSYS + + +=head1 DESCRIPTION + +RT session class and utilities. + +CLASS METHODS can be used without creating object instances, +it's mainly utilities to clean unused session records. + +Object is tied hash and can be used to access session data. + +=head1 METHODS + +=head2 CLASS METHODS + +=head3 Class + +Returns name of the class that is used as sessions storage. + +=cut + +sub Class { + my $self = shift; + + my $class = RT->Config->Get('WebSessionClass') + || $self->Backends->{RT->Config->Get('DatabaseType')} + || 'Apache::Session::File'; + eval "require $class"; + die $@ if $@; + return $class; +} + +=head3 Backends + +Returns hash reference with names of the databases as keys and +sessions class names as values. + +=cut + +sub Backends { + return { + mysql => 'Apache::Session::MySQL', + Pg => 'Apache::Session::Postgres', + }; +} + +=head3 Attributes + +Returns hash reference with attributes that are used to create +new session objects. + +=cut + +sub Attributes { + + return $_[0]->Backends->{RT->Config->Get('DatabaseType')} ? { + Handle => $RT::Handle->dbh, + LockHandle => $RT::Handle->dbh, + Transaction => 1, + } : { + Directory => $RT::MasonSessionDir, + LockDirectory => $RT::MasonSessionDir, + Transaction => 1, + }; +} + +=head3 Ids + +Returns array ref with list of the session IDs. + +=cut + +sub Ids { + my $self = shift || __PACKAGE__; + my $attributes = $self->Attributes; + if( $attributes->{Directory} ) { + return $self->_IdsDir( $attributes->{Directory} ); + } else { + return $self->_IdsDB( $RT::Handle->dbh ); + } +} + +sub _IdsDir { + my ($self, $dir) = @_; + require File::Find; + my %file; + File::Find::find( + sub { return unless /^[a-zA-Z0-9]+$/; + $file{$_} = (stat($_))[9]; + }, + $dir, + ); + + return [ sort { $file{$a} <=> $file{$b} } keys %file ]; +} + +sub _IdsDB { + my ($self, $dbh) = @_; + my $ids = $dbh->selectcol_arrayref("SELECT id FROM sessions ORDER BY LastUpdated DESC"); + die "couldn't get ids: ". $dbh->errstr if $dbh->errstr; + return $ids; +} + +=head3 ClearOld + +Takes seconds and deletes all sessions that are older. + +=cut + +sub ClearOld { + my $class = shift || __PACKAGE__; + my $attributes = $class->Attributes; + if( $attributes->{Directory} ) { + return $class->_CleariOldDir( $attributes->{Directory}, @_ ); + } else { + return $class->_ClearOldDB( $RT::Handle->dbh, @_ ); + } +} + +sub _ClearOldDB { + my ($self, $dbh, $older_than) = @_; + my $rows; + unless( int $older_than ) { + $rows = $dbh->do("DELETE FROM sessions"); + die "couldn't delete sessions: ". $dbh->errstr unless defined $rows; + } else { + require POSIX; + my $date = POSIX::strftime("%Y-%m-%d %H:%M", localtime( time - int $older_than ) ); + + my $sth = $dbh->prepare("DELETE FROM sessions WHERE LastUpdated < ?"); + die "couldn't prepare query: ". $dbh->errstr unless $sth; + $rows = $sth->execute( $date ); + die "couldn't execute query: ". $dbh->errstr unless defined $rows; + } + + $RT::Logger->info("successfuly deleted $rows sessions"); + return; +} + +sub _ClearOldDir { + my ($self, $dir, $older_than) = @_; + + require File::Spec if int $older_than; + + my $now = time; + my $class = $self->Class; + my $attrs = $self->Attributes; + + foreach my $id( @{ $self->Ids } ) { + if( int $older_than ) { + my $ctime = (stat(File::Spec->catfile($dir,$id)))[9]; + if( $ctime > $now - $older_than ) { + $RT::Logger->debug("skipped session '$id', isn't old"); + next; + } + } + + my %session; + local $@; + eval { tie %session, $class, $id, $attrs }; + if( $@ ) { + $RT::Logger->debug("skipped session '$id', couldn't load: $@"); + next; + } + tied(%session)->delete; + $RT::Logger->info("successfuly deleted session '$id'"); + } + return; +} + +=head3 ClearByUser + +Checks all sessions and if user has more then one session +then leave only the latest one. + +=cut + +sub ClearByUser { + my $self = shift || __PACKAGE__; + my $class = $self->Class; + my $attrs = $self->Attributes; + + my %seen = (); + foreach my $id( @{ $self->Ids } ) { + my %session; + local $@; + eval { tie %session, $class, $id, $attrs }; + if( $@ ) { + $RT::Logger->debug("skipped session '$id', couldn't load: $@"); + next; + } + if( $session{'CurrentUser'} && $session{'CurrentUser'}->id ) { + unless( $seen{ $session{'CurrentUser'}->id }++ ) { + $RT::Logger->debug("skipped session '$id', first user's session"); + next; + } + } + tied(%session)->delete; + $RT::Logger->info("successfuly deleted session '$id'"); + } +} + +sub TIEHASH { + my $self = shift; + my $id = shift; + + my $class = $self->Class; + my $attrs = $self->Attributes; + + my %session; + + local $@; + eval { tie %session, $class, $id, $attrs }; + eval { tie %session, $class, undef, $attrs } if $@; + if ( $@ ) { + die loc("RT couldn't store your session.") . "\n" + . loc("This may mean that that the directory '[_1]' isn't writable or a database table is missing or corrupt.", + $RT::MasonSessionDir) + . "\n\n" + . $@; + } + + return tied %session; +} + +1; diff --git a/rt/lib/RT/Interface/Web/Standalone.pm b/rt/lib/RT/Interface/Web/Standalone.pm index f625dd8e8..12bd276e1 100755 --- a/rt/lib/RT/Interface/Web/Standalone.pm +++ b/rt/lib/RT/Interface/Web/Standalone.pm @@ -1,8 +1,8 @@ # BEGIN BPS TAGGED BLOCK {{{ # # COPYRIGHT: -# -# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC # <jesse@bestpractical.com> # # (Except where explicitly superseded by other copyright notices) @@ -45,12 +45,15 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} -package RT::Interface::Web::Standalone; use strict; +use warnings; +package RT::Interface::Web::Standalone; + use base 'HTTP::Server::Simple::Mason'; use RT::Interface::Web::Handler; use RT::Interface::Web; +use URI; sub handler_class { "RT::Interface::Web::Handler" } @@ -64,7 +67,7 @@ sub setup_escapes { } sub default_mason_config { - return @RT::MasonParameters; + return RT->Config->Get('MasonParameters'); } sub handle_request { @@ -72,13 +75,43 @@ sub handle_request { my $self = shift; my $cgi = shift; - Module::Refresh->refresh if $RT::DevelMode; - + Module::Refresh->refresh if RT->Config->Get('DevelMode'); + RT::ConnectToDatabase() unless RT->InstallMode; $self->SUPER::handle_request($cgi); - $RT::Logger->crit($@) if ($@); - + $RT::Logger->crit($@) if $@ && $RT::Logger; + warn $@ if $@ && !$RT::Logger; RT::Interface::Web::Handler->CleanupRequest(); +} + +sub net_server { + my $self = shift; + $self->{rt_net_server} = shift if @_; + return $self->{rt_net_server}; +} + + +=head2 print_banner + +This routine prints a banner before the server request-handling loop +starts. + +Methods below this point are probably not terribly useful to define +yourself in subclasses. + +=cut + +sub print_banner { + my $self = shift; + + my $url = URI->new( RT->Config->Get('WebBaseURL')); + $url->host('127.0.0.1') if ($url->host() eq 'localhost'); + $url->port($self->port); + print( + "You can connect to your server at " + . $url->canonical + . "\n" ); } + 1; diff --git a/rt/lib/RT/Interface/Web/Standalone/PreFork.pm b/rt/lib/RT/Interface/Web/Standalone/PreFork.pm new file mode 100644 index 000000000..c00f8cd64 --- /dev/null +++ b/rt/lib/RT/Interface/Web/Standalone/PreFork.pm @@ -0,0 +1,103 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC +# <jesse@bestpractical.com> +# +# (Except where explicitly superseded by other copyright notices) +# +# +# LICENSE: +# +# This work is made available to you under the terms of Version 2 of +# the GNU General Public License. A copy of that license should have +# been provided with this software, but in any event can be snarfed +# from www.gnu.org. +# +# This work 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. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 or visit their web page on the internet at +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. +# +# +# CONTRIBUTION SUBMISSION POLICY: +# +# (The following paragraph is not intended to limit the rights granted +# to you to modify and distribute this software under the terms of +# the GNU General Public License and is only of importance to you if +# you choose to contribute your changes and enhancements to the +# community by submitting them to Best Practical Solutions, LLC.) +# +# By intentionally submitting any modifications, corrections or +# derivatives to this work, or any other work intended for use with +# Request Tracker, to Best Practical Solutions, LLC, you confirm that +# you are the copyright holder for those contributions and you grant +# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, +# royalty-free, perpetual, license to use, copy, create derivative +# works based on those contributions, and sublicense and distribute +# those contributions and any derivatives thereof. +# +# END BPS TAGGED BLOCK }}} + +use warnings; +use strict; + +package RT::Interface::Web::Standalone::PreFork; +use base qw/Net::Server::PreFork/; + +my %option_map = ( + min_servers => 'StandaloneMinServers', + max_servers => 'StandaloneMaxServers', + min_spare_servers => 'StandaloneMinSpareServers', + max_spare_servers => 'StandaloneMaxSpareServers', + max_requests => 'StandaloneMaxRequests', +); + +=head2 default_values + +Produces the default values for L<Net::Server> configuration from RT's config +files. + +=cut + +sub default_values { + my %forking = ( + map { $_ => RT->Config->Get( $option_map{$_} ) } + grep { defined( RT->Config->Get( $option_map{$_} ) ) } + keys %option_map, + ); + + return { + %forking, + log_level => 1, + RT->Config->Get('NetServerOptions') + }; +} + +=head2 post_bind_hook + +After binding to the specified ports, let the user know that the server is +prepared to handle connections. + +=cut + +sub post_bind_hook { + my $self = shift; + my @ports = @{ $self->{server}->{port} }; + + print $0 + . ": You can connect to your server at " + . (join ' , ', map { "http://localhost:$_/" } @ports) + . "\n"; + + $self->SUPER::post_bind_hook(@_); +} + +1; |