From 1c538bfabc2cd31f27067505f0c3d1a46cba6ef0 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Thu, 9 Jul 2015 22:18:55 -0700 Subject: RT 4.2.11, ticket#13852 --- rt/lib/RT/Interface/CLI.pm | 195 +- rt/lib/RT/Interface/Email.pm | 533 ++- rt/lib/RT/Interface/Email.pm.orig | 81 +- rt/lib/RT/Interface/Email/Auth/Crypt.pm | 294 ++ rt/lib/RT/Interface/Email/Auth/GnuPG.pm | 257 -- rt/lib/RT/Interface/REST.pm | 59 +- rt/lib/RT/Interface/Web.pm | 1358 ++++++-- rt/lib/RT/Interface/Web.pm.orig | 3454 -------------------- rt/lib/RT/Interface/Web/Handler.pm | 96 +- rt/lib/RT/Interface/Web/Menu.pm | 14 +- .../RT/Interface/Web/Middleware/StaticHeaders.pm | 80 + rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm | 7 +- rt/lib/RT/Interface/Web/Request.pm | 3 - rt/lib/RT/Interface/Web/Session.pm | 30 +- 14 files changed, 1936 insertions(+), 4525 deletions(-) create mode 100644 rt/lib/RT/Interface/Email/Auth/Crypt.pm delete mode 100755 rt/lib/RT/Interface/Email/Auth/GnuPG.pm delete mode 100644 rt/lib/RT/Interface/Web.pm.orig create mode 100644 rt/lib/RT/Interface/Web/Middleware/StaticHeaders.pm (limited to 'rt/lib/RT/Interface') diff --git a/rt/lib/RT/Interface/CLI.pm b/rt/lib/RT/Interface/CLI.pm index 5faa8ca08..f992f166b 100644 --- a/rt/lib/RT/Interface/CLI.pm +++ b/rt/lib/RT/Interface/CLI.pm @@ -49,10 +49,11 @@ package RT::Interface::CLI; use strict; use warnings; -use RT; + +use RT::Base; use base 'Exporter'; -our @EXPORT_OK = qw(CleanEnv GetCurrentUser GetMessageContent debug loc); +our @EXPORT_OK = qw(CleanEnv GetCurrentUser debug loc Init); =head1 NAME @@ -60,27 +61,18 @@ our @EXPORT_OK = qw(CleanEnv GetCurrentUser GetMessageContent debug loc); =head1 SYNOPSIS - use lib "/path/to/rt/libraries/"; - - use RT::Interface::CLI qw(CleanEnv - GetCurrentUser GetMessageContent loc); - - #Clean out all the nasties from the environment - CleanEnv(); + use lib "/opt/rt4/local/lib", "/opt/rt4/lib"; - #let's talk to RT' - use RT; + use RT::Interface::CLI qw(GetCurrentUser Init loc); - #Load RT's config file - RT::LoadConfig(); + # Process command-line arguments, load the configuration, and connect + # to the database + Init(); - # Connect to the database. set up loggign - RT::Init(); - - #Get the current user all loaded + # Get the current user all loaded my $CurrentUser = GetCurrentUser(); - print loc('Hello!'); # Synonym of $CuurentUser->loc('Hello!'); + print loc('Hello!'); # Synonym of $CurrentUser->loc('Hello!'); =head1 DESCRIPTION @@ -98,11 +90,13 @@ Removes some of the nastiest nasties from the user's environment. =cut sub CleanEnv { + RT->Deprecated( Remove => "4.4" ); + $ENV{'PATH'} = '/bin:/usr/bin'; # or whatever you need $ENV{'CDPATH'} = '' if defined $ENV{'CDPATH'}; $ENV{'SHELL'} = '/bin/sh' if defined $ENV{'SHELL'}; $ENV{'ENV'} = '' if defined $ENV{'ENV'}; - $ENV{'IFS'} = '' if defined $ENV{'IFS'}; + $ENV{'IFS'} = '' if defined $ENV{'IFS'}; } @@ -121,28 +115,26 @@ loaded with that user. if the current user isn't found, returns a copy of RT::N =cut sub GetCurrentUser { - + require RT::CurrentUser; - + #Instantiate a user object - - my $Gecos= ($^O eq 'MSWin32') ? Win32::LoginName() : (getpwuid($<))[0]; + + my $Gecos= (getpwuid($<))[0]; #If the current user is 0, then RT will assume that the User object #is that of the currentuser. $CurrentUser = RT::CurrentUser->new(); $CurrentUser->LoadByGecos($Gecos); - + unless ($CurrentUser->Id) { - $RT::Logger->debug("No user with a unix login of '$Gecos' was found. "); + $RT::Logger->error("No user with a GECOS (unix login) of '$Gecos' was found."); } return($CurrentUser); } - - =head2 loc Synonym of $CurrentUser->loc(). @@ -156,82 +148,9 @@ sub loc { } - - -=head2 GetMessageContent - -Takes two arguments a source file and a boolean "edit". If the source file -is undef or "", assumes an empty file. Returns an edited file as an -array of lines. - -=cut - -sub GetMessageContent { - my %args = ( Source => undef, - Content => undef, - Edit => undef, - CurrentUser => undef, - @_); - my $source = $args{'Source'}; - - my $edit = $args{'Edit'}; - - my $currentuser = $args{'CurrentUser'}; - my @lines; - - use File::Temp qw/ tempfile/; - - #Load the sourcefile, if it's been handed to us - if ($source) { - open( SOURCE, '<', $source ) or die $!; - @lines = () or die $!; - close (SOURCE) or die $!; - } - elsif ($args{'Content'}) { - @lines = split('\n',$args{'Content'}); - } - #get us a tempfile. - my ($fh, $filename) = tempfile(); - - #write to a tmpfile - for (@lines) { - print $fh $_; - } - close ($fh) or die $!; - - #Edit the file if we need to - if ($edit) { - - unless ($ENV{'EDITOR'}) { - $RT::Logger->crit('No $EDITOR variable defined'); - return undef; - } - system ($ENV{'EDITOR'}, $filename); - } - - open( READ, '<', $filename ) or die $!; - my @newlines = (); - close (READ) or die $!; - - unlink ($filename) unless (debug()); - return(\@newlines); - -} - - - sub debug { - my $val = shift; - my ($debug); - if ($val) { - $RT::Logger->debug($val); - if ($debug) { - print STDERR "$val\n"; - } - } - if ($debug) { - return(1); - } + RT->Deprecated( Remove => "4.4", Instead => '$RT::Logger->debug' ); + $RT::Logger->debug(@_); } sub ShowHelp { @@ -249,6 +168,78 @@ sub ShowHelp { ); } +=head2 Init + +A shim for L which automatically adds a +C<--help> option if it is not supplied. It then calls L +and L. + +It sets the C setting to C, to ensure that the +user sees all relevant warnings. It also adds C<--quiet> and +C<--verbose> options, which adjust the C value to C +or C, respectively. + +=cut + +sub Init { + require Getopt::Long; + require Pod::Usage; + + my %exists; + my @args; + my $hash; + if (ref $_[0]) { + $hash = shift(@_); + for (@_) { + m/^([a-zA-Z0-9-]+)/; + $exists{$1}++; + push @args, $_ => \($hash->{$1}); + } + } else { + $hash = {}; + @args = @_; + while (@_) { + my $key = shift(@_); + $exists{$key}++; + shift(@_); + } + } + + push @args, "help|h!" => \($hash->{help}) + unless $exists{help}; + + push @args, "verbose|v!" => \($hash->{verbose}) + unless $exists{verbose}; + + push @args, "quiet|q!" => \($hash->{quiet}) + unless $exists{quiet}; + + my $ok = Getopt::Long::GetOptions( @args ); + Pod::Usage::pod2usage(1) if not $ok and not defined wantarray; + + return unless $ok; + + Pod::Usage::pod2usage({ verbose => 2}) + if not $exists{help} and $hash->{help}; + + require RT; + RT::LoadConfig(); + + if (not $exists{quiet} and $hash->{quiet}) { + RT->Config->Set(LogToSTDERR => "error"); + } elsif (not $exists{verbose} and $hash->{verbose}) { + RT->Config->Set(LogToSTDERR => "debug"); + } else { + RT->Config->Set(LogToSTDERR => "warning"); + } + + RT::Init(); + + $| = 1; + + return $ok; +} + RT::Base->_ImportOverlays(); 1; diff --git a/rt/lib/RT/Interface/Email.pm b/rt/lib/RT/Interface/Email.pm index f860461e0..87e24e700 100755 --- a/rt/lib/RT/Interface/Email.pm +++ b/rt/lib/RT/Interface/Email.pm @@ -50,27 +50,25 @@ package RT::Interface::Email; use strict; use warnings; +use 5.010; use Email::Address; use MIME::Entity; use RT::EmailParser; use File::Temp; -use UNIVERSAL::require; use Mail::Mailer (); use Text::ParseWords qw/shellwords/; +use RT::Util 'safe_run_child'; +use File::Spec; BEGIN { use base 'Exporter'; use vars qw ( @EXPORT_OK); - # set the version for version checking - our $VERSION = 2.0; - # your exported package globals go here, # as well as any optionally exported functions @EXPORT_OK = qw( &CreateUser - &GetMessageContent &CheckForLoops &CheckForSuspiciousSender &CheckForAutoGenerated @@ -165,17 +163,16 @@ sub CheckForSuspiciousSender { =head2 CheckForAutoGenerated HEAD -Takes a HEAD object of L class and returns true if message -is autogenerated. Checks 'Precedence' and 'X-FC-Machinegenerated' -fields of the head in tests. +Takes a HEAD object of L class and returns true if message is +autogenerated. Checks C, C, and +C fields of the head in tests. =cut sub CheckForAutoGenerated { my $head = shift; - my $Precedence = $head->get("Precedence") || ""; - if ( $Precedence =~ /^(bulk|junk)/i ) { + if (grep { /^(bulk|junk)/i } $head->get_all("Precedence")) { return (1); } @@ -331,7 +328,7 @@ sub WillSignEncrypt { my $attachment = delete $args{Attachment}; my $ticket = delete $args{Ticket}; - if ( not RT->Config->Get('GnuPG')->{'Enable'} ) { + if ( not RT->Config->Get('Crypt')->{'Enable'} ) { $args{Sign} = $args{Encrypt} = 0; return wantarray ? %args : 0; } @@ -367,13 +364,6 @@ sub SendEmail { 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; @@ -396,13 +386,35 @@ sub SendEmail { return -1; } + if (my $precedence = RT->Config->Get('DefaultMailPrecedence') + and !$args{'Entity'}->head->get("Precedence") + ) { + $args{'Entity'}->head->replace( 'Precedence', Encode::encode("UTF-8",$precedence) ); + } + if ( $TransactionObj && !$TicketObj && $TransactionObj->ObjectType eq 'RT::Ticket' ) { $TicketObj = $TransactionObj->Object; } - if ( RT->Config->Get('GnuPG')->{'Enable'} ) { + my $head = $args{'Entity'}->head; + unless ( $head->get('Date') ) { + require RT::Date; + my $date = RT::Date->new( RT->SystemUser ); + $date->SetToNow; + $head->replace( 'Date', Encode::encode("UTF-8",$date->RFC2822( Timezone => 'server' ) ) ); + } + unless ( $head->get('MIME-Version') ) { + # We should never have to set the MIME-Version header + $head->replace( 'MIME-Version', '1.0' ); + } + unless ( $head->get('Content-Transfer-Encoding') ) { + # fsck.com #5959: Since RT sends 8bit mail, we should say so. + $head->replace( 'Content-Transfer-Encoding', '8bit' ); + } + + if ( RT->Config->Get('Crypt')->{'Enable'} ) { %args = WillSignEncrypt( %args, Attachment => $TransactionObj ? $TransactionObj->Attachments->First : undef, @@ -412,26 +424,15 @@ sub SendEmail { 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', Encode::encode( "UTF-8", $date->RFC2822( Timezone => 'server' ) ) ); - } - my $mail_command = RT->Config->Get('MailCommand'); - if ($mail_command eq 'testfile' and not $Mail::Mailer::testfile::config{outfile}) { - $Mail::Mailer::testfile::config{outfile} = File::Temp->new; - $RT::Logger->info("Storing outgoing emails in $Mail::Mailer::testfile::config{outfile}"); - } - # 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 = shellwords(RT->Config->Get('SendmailArguments')); + push @args, "-t" unless grep {$_ eq "-t"} @args; # SetOutgoingMailFrom and bounces conflict, since they both want -f if ( $args{'Bounce'} ) { @@ -441,14 +442,15 @@ sub SendEmail { my $Overrides = RT->Config->Get('OverrideOutgoingMailFrom') || {}; if ($TicketObj) { - my $QueueName = $TicketObj->QueueObj->Name; - my $QueueAddressOverride = $Overrides->{$QueueName}; + my $Queue = $TicketObj->QueueObj; + my $QueueAddressOverride = $Overrides->{$Queue->id} + || $Overrides->{$Queue->Name}; if ($QueueAddressOverride) { $OutgoingMailAddress = $QueueAddressOverride; } else { - $OutgoingMailAddress ||= $TicketObj->QueueObj->CorrespondAddress - || RT->Config->Get('CorrespondAddress'); + $OutgoingMailAddress ||= $Queue->CorrespondAddress + || RT->Config->Get('CorrespondAddress'); } } elsif ($Overrides->{'Default'}) { @@ -502,62 +504,42 @@ sub SendEmail { } 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."); - if ($TicketObj) { - _RecordSendEmailFailure( $TicketObj ); - } - return 0; + } elsif ( $mail_command eq 'mbox' ) { + my $now = RT::Date->new(RT->SystemUser); + $now->SetToNow; + + state $logfile; + unless ($logfile) { + my $when = $now->ISO( Timezone => "server" ); + $when =~ s/\s+/-/g; + $logfile = "$RT::VarPath/$when.mbox"; + $RT::Logger->info("Storing outgoing emails in $logfile"); } - # duplicate head as we want drop Bcc field - my $head = $args{'Entity'}->head->dup; - my @recipients = map $_->address, map - Email::Address->parse(Encode::decode("UTF-8", $head->get($_))), - qw(To Cc Bcc); - $head->delete('Bcc'); - - my $sender = RT->Config->Get('SMTPFrom') - || Encode::decode( "UTF-8", $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." ); - if ( $TicketObj ) { - _RecordSendEmailFailure( $TicketObj ); - } + my $fh; + unless (open($fh, ">>", $logfile)) { + $RT::Logger->crit( "Can't open mbox file $logfile: $!" ); return 0; } - } - else { + my $content = $args{Entity}->stringify; + $content =~ s/^(>*From )/>$1/mg; + print $fh "From $ENV{USER}\@localhost ".localtime."\n"; + print $fh $content, "\n"; + close $fh; + } 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, grep {$_ ne "-t"} + split(/\s+/, RT->Config->Get('SendmailArguments')); + } elsif ( $mail_command eq 'testfile' ) { + unless ($Mail::Mailer::testfile::config{outfile}) { + $Mail::Mailer::testfile::config{outfile} = File::Temp->new; + $RT::Logger->info("Storing outgoing emails in $Mail::Mailer::testfile::config{outfile}"); + } + } else { push @mailer_args, RT->Config->Get('MailParams'); } @@ -630,10 +612,10 @@ sub SendEmailUsingTemplate { return -1; } - $mail->head->set( $_ => Encode::encode( "UTF-8", $args{ $_ } ) ) + $mail->head->replace( $_ => Encode::encode( "UTF-8", $args{ $_ } ) ) foreach grep defined $args{$_}, qw(To Cc Bcc From); - $mail->head->set( $_ => Encode::encode( "UTF-8", $args{ExtraHeaders}{$_} ) ) + $mail->head->replace( $_ => Encode::encode( "UTF-8", $args{ExtraHeaders}{$_} ) ) foreach keys %{ $args{ExtraHeaders} }; SetInReplyTo( Message => $mail, InReplyTo => $args{'InReplyTo'} ); @@ -641,195 +623,58 @@ sub SendEmailUsingTemplate { 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; - - my ( $ret, $msg ) = SendForward( %args, Entity => $entity, Transaction => $txn ); - if ($ret) { - my $ticket = $txn->TicketObj; - my ( $ret, $msg ) = $ticket->_NewTransaction( - Type => 'Forward Transaction', - Field => $txn->id, - Data => join ', ', grep { length } $args{To}, $args{Cc}, $args{Bcc}, - ); - unless ($ret) { - $RT::Logger->error("Failed to create transaction: $msg"); - } - } - return ( $ret, $msg ); -} - -=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', - Description => 'forwarded ticket', - ); - $entity->add_part( $_ ) foreach - map $_->ContentAsMIME, - @{ $txns->ItemsArrayRef }; - - my ( $ret, $msg ) = SendForward( - %args, - Entity => $entity, - Ticket => $ticket, - Template => 'Forward Ticket', - ); - - if ($ret) { - my ( $ret, $msg ) = $ticket->_NewTransaction( - Type => 'Forward Ticket', - Field => $ticket->id, - Data => join ', ', grep { length } $args{To}, $args{Cc}, $args{Bcc}, - ); - unless ($ret) { - $RT::Logger->error("Failed to create transaction: $msg"); - } - } - - return ( $ret, $msg ); - -} - -=head2 SendForward Entity => undef, Ticket => undef, Transaction => undef, Template => undef, To => '', Cc => '', Bcc => '' +=head2 GetForwardFrom Ticket => undef, Transaction => undef -Forwards an Entity representing Ticket or Transaction as 'message/rfc822'. Entity is wrapped into Template. +Resolve the From field to use in forward mail =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 { - $RT::Logger->warning($msg); - } - unless ( $mail ) { - $RT::Logger->warning("Couldn't generate email using template '$args{Template}'"); +sub GetForwardFrom { + my %args = ( Ticket => undef, Transaction => undef, @_ ); + my $txn = $args{Transaction}; + my $ticket = $args{Ticket} || $txn->Object; - 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', - Charset => "UTF-8", - Data => Encode::encode( "UTF-8", $description ), - ); + if ( RT->Config->Get('ForwardFromUser') ) { + return ( $txn || $ticket )->CurrentUser->EmailAddress; } - - $mail->head->set( $_ => EncodeToMIME( String => $args{$_} ) ) - foreach grep defined $args{$_}, qw(To Cc Bcc); - - $mail->make_multipart unless $mail->is_multipart; - $mail->add_part( $entity ); - - my $from; - unless (defined $mail->head->get('Subject')) { - my $subject = ''; - $subject = $txn->Subject if $txn; - $subject ||= $ticket->Subject if $ticket; - - unless ( RT->Config->Get('ForwardFromUser') ) { - # XXX: what if want to forward txn of other object than ticket? - $subject = AddSubjectTag( $subject, $ticket ); - } - - $mail->head->set( Subject => EncodeToMIME( String => "Fwd: $subject" ) ); + else { + return $ticket->QueueObj->CorrespondAddress + || RT->Config->Get('CorrespondAddress'); } - - $mail->head->set( - From => EncodeToMIME( - String => GetForwardFrom( Transaction => $txn, Ticket => $ticket ) - ) - ); - - 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("Sent email successfully")); } -=head2 GetForwardFrom Ticket => undef, Transaction => undef +=head2 GetForwardAttachments Ticket => undef, Transaction => undef -Resolve the From field to use in forward mail +Resolve the Attachments to forward =cut -sub GetForwardFrom { +sub GetForwardAttachments { my %args = ( Ticket => undef, Transaction => undef, @_ ); my $txn = $args{Transaction}; my $ticket = $args{Ticket} || $txn->Object; - if ( RT->Config->Get('ForwardFromUser') ) { - return ( $txn || $ticket )->CurrentUser->EmailAddress; + my $attachments = RT::Attachments->new( $ticket->CurrentUser ); + if ($txn) { + $attachments->Limit( FIELD => 'TransactionId', VALUE => $txn->id ); } else { - return $ticket->QueueObj->CorrespondAddress - || RT->Config->Get('CorrespondAddress'); + $attachments->LimitByTicket( $ticket->id ); + $attachments->Limit( + ALIAS => $attachments->TransactionAlias, + FIELD => 'Type', + OPERATOR => 'IN', + VALUE => [ qw(Create Correspond) ], + ); } + return $attachments; } + =head2 SignEncrypt Entity => undef, Sign => 0, Encrypt => 0 -Signs and encrypts message using L, but as well -handle errors with users' keys. +Signs and encrypts message using L, 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. @@ -857,11 +702,12 @@ sub SignEncrypt { $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 ); + my %res = RT::Crypt->SignEncrypt( %args ); return 1 unless $res{'exit_code'}; - my @status = RT::Crypt::GnuPG::ParseStatus( $res{'status'} ); + my @status = RT::Crypt->ParseStatus( + Protocol => $res{'Protocol'}, Status => $res{'status'}, + ); my @bad_recipients; foreach my $line ( @status ) { @@ -925,7 +771,7 @@ sub SignEncrypt { } # redo without broken recipients - %res = RT::Crypt::GnuPG::SignEncrypt( %args ); + %res = RT::Crypt->SignEncrypt( %args ); return 0 if $res{'exit_code'}; return 1; @@ -1203,7 +1049,7 @@ sub DeleteRecipientsFromHead { my %skip = map { lc $_ => 1 } @_; foreach my $field ( qw(To Cc Bcc) ) { - $head->set( $field => Encode::encode( "UTF-8", + $head->replace( $field => Encode::encode( "UTF-8", join ', ', map $_->format, grep !$skip{ lc $_->address }, Email::Address->parse( Encode::decode( "UTF-8", $head->get( $field ) ) ) ) ); @@ -1254,17 +1100,35 @@ sub SetInReplyTo { } push @references, @id, @rtid; if ( $args{'Ticket'} ) { - my $pseudo_ref = 'id .'@'. RT->Config->Get('Organization') .'>'; + my $pseudo_ref = PseudoReference( $args{'Ticket'} ); push @references, $pseudo_ref unless grep $_ eq $pseudo_ref, @references; } - @references = splice @references, 4, -6 + splice @references, 4, -6 if @references > 10; my $mail = $args{'Message'}; - $mail->head->set( 'In-Reply-To' => Encode::encode( "UTF-8", join ' ', @rtid? (@rtid) : (@id)) ) if @id || @rtid; - $mail->head->set( 'References' => Encode::encode( "UTF-8", join ' ', @references) ); + $mail->head->replace( 'In-Reply-To' => Encode::encode( "UTF-8", join ' ', @rtid? (@rtid) : (@id)) ) if @id || @rtid; + $mail->head->replace( 'References' => Encode::encode( "UTF-8", join ' ', @references) ); } +sub PseudoReference { + my $ticket = shift; + return 'id .'@'. RT->Config->Get('Organization') .'>'; +} + +=head2 ExtractTicketId + +Passed a MIME::Entity. Returns a ticket id or undef to signal 'new ticket'. + +This is a great entry point if you need to customize how ticket ids are +handled for your site. RT-Extension-RepliesToResolved demonstrates one +possible use for this extension. + +If the Subject of this ticket is modified, it will be reloaded by the +mail gateway code before Ticket creation. + +=cut + sub ExtractTicketId { my $entity = shift; @@ -1273,19 +1137,29 @@ sub ExtractTicketId { return ParseTicketId( $subject ); } +=head2 ParseTicketId + +Takes a string and searches for [subjecttag #id] + +Returns the id if a match is found. Otherwise returns undef. + +=cut + sub ParseTicketId { my $Subject = shift; my $rtname = RT->Config->Get('rtname'); my $test_name = RT->Config->Get('EmailSubjectTagRegex') || qr/\Q$rtname\E/i; + # We use @captures and pull out the last capture value to guard against + # someone using (...) instead of (?:...) in $EmailSubjectTagRegex. my $id; - if ( $Subject =~ s/\[$test_name\s+\#(\d+)\s*\]//i ) { - $id = $1; + if ( my @captures = $Subject =~ /\[$test_name\s+\#(\d+)\s*\]/i ) { + $id = $captures[-1]; } else { foreach my $tag ( RT->System->SubjectTag ) { - next unless $Subject =~ s/\[\Q$tag\E\s+\#(\d+)\s*\]//i; - $id = $1; + next unless my @captures = $Subject =~ /\[\Q$tag\E\s+\#(\d+)\s*\]/i; + $id = $captures[-1]; last; } } @@ -1434,6 +1308,10 @@ sub Gateway { push @mail_plugins, "Auth::MailFrom" unless @mail_plugins; @mail_plugins = _LoadPlugins( @mail_plugins ); + #Set up a queue object + my $SystemQueueObj = RT::Queue->new( RT->SystemUser ); + $SystemQueueObj->Load( $args{'queue'} ); + my %skip_plugin; foreach my $class( grep !ref, @mail_plugins ) { # check if we should apply filter before decoding @@ -1445,6 +1323,8 @@ sub Gateway { next unless $check_cb->( Message => $Message, RawMessageRef => \$args{'message'}, + Queue => $SystemQueueObj, + Actions => \@actions, ); $skip_plugin{ $class }++; @@ -1456,6 +1336,8 @@ sub Gateway { my ($status, $msg) = $Code->( Message => $Message, RawMessageRef => \$args{'message'}, + Queue => $SystemQueueObj, + Actions => \@actions, ); next if $status > 0; @@ -1514,10 +1396,6 @@ sub Gateway { $Right = 'CreateTicket'; } - #Set up a queue object - my $SystemQueueObj = RT::Queue->new( RT->SystemUser ); - $SystemQueueObj->Load( $args{'queue'} ); - # We can safely have no queue of we have a known-good ticket unless ( $SystemTicket->id || $SystemQueueObj->id ) { return ( -75, "RT couldn't find the queue: " . $args{'queue'}, undef ); @@ -1572,6 +1450,8 @@ sub Gateway { return ( 0, $result, undef ); } + $head->replace('X-RT-Interface' => 'Email'); + # if plugin's updated SystemTicket then update arguments $args{'ticket'} = $SystemTicket->Id if $SystemTicket && $SystemTicket->Id; @@ -1591,8 +1471,6 @@ sub Gateway { ); } - $head->replace('X-RT-Interface' => 'Email'); - my ( $id, $Transaction, $ErrStr ) = $Ticket->Create( Queue => $SystemQueueObj->Id, Subject => $NewSubject, @@ -1932,9 +1810,10 @@ sub IsCorrectAction { sub _RecordSendEmailFailure { my $ticket = shift; if ($ticket) { - $ticket->_RecordNote( - NoteType => 'SystemError', - Content => "Sending the previous mail has failed. Please contact your admin, they can find more details in the logs.", + $ticket->_NewTransaction( + Type => "SystemError", + Data => "Sending the previous mail has failed. Please contact your admin, they can find more details in the logs.", #loc + ActivateScrips => 0, ); return 1; } @@ -1944,6 +1823,118 @@ sub _RecordSendEmailFailure { } } +=head2 ConvertHTMLToText HTML + +Takes HTML characters and converts it to plain text characters. +Appropriate for generating a plain text part from an HTML part of an +email. Returns undef if conversion fails. + +=cut + +sub ConvertHTMLToText { + return _HTMLFormatter()->(@_); +} + +sub _HTMLFormatter { + state $formatter; + return $formatter if defined $formatter; + + my $wanted = RT->Config->Get("HTMLFormatter"); + + my @order; + if ($wanted) { + @order = ($wanted, "core"); + } else { + @order = ("w3m", "elinks", "links", "html2text", "lynx", "core"); + } + # Always fall back to core, even if it is not listed + for my $prog (@order) { + if ($prog eq "core") { + RT->Logger->debug("Using internal Perl HTML -> text conversion"); + require HTML::FormatText::WithLinks::AndTables; + $formatter = \&_HTMLFormatText; + } else { + unless (HTML::FormatExternal->require) { + RT->Logger->warn("HTML::FormatExternal is not installed; falling back to internal perl formatter") + if $wanted; + next; + } + + my $path = $prog =~ s{(.*/)}{} ? $1 : undef; + my $package = "HTML::FormatText::" . ucfirst($prog); + unless ($package->require) { + RT->Logger->warn("$prog is not a valid formatter provided by HTML::FormatExternal") + if $wanted; + next; + } + + if ($path) { + local $ENV{PATH} = $path; + local $ENV{HOME} = File::Spec->tmpdir(); + if (not defined $package->program_version) { + RT->Logger->warn("Could not find or run external '$prog' HTML formatter in $path$prog") + if $wanted; + next; + } + } else { + local $ENV{PATH} = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + unless defined $ENV{PATH}; + local $ENV{HOME} = File::Spec->tmpdir(); + if (not defined $package->program_version) { + RT->Logger->warn("Could not find or run external '$prog' HTML formatter in \$PATH ($ENV{PATH}) -- you may need to install it or provide the full path") + if $wanted; + next; + } + } + + RT->Logger->debug("Using $prog for HTML -> text conversion"); + $formatter = sub { + my $html = shift; + my $text = RT::Util::safe_run_child { + local $ENV{PATH} = $path || $ENV{PATH} + || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + local $ENV{HOME} = File::Spec->tmpdir(); + $package->format_string( + Encode::encode( "UTF-8", $html ), + input_charset => "UTF-8", + output_charset => "UTF-8", + leftmargin => 0, rightmargin => 78 + ); + }; + $text = Encode::decode( "UTF-8", $text ); + return $text; + }; + } + RT->Config->Set( HTMLFormatter => $prog ); + last; + } + return $formatter; +} + +sub _HTMLFormatText { + my $html = shift; + + my $text; + eval { + $text = HTML::FormatText::WithLinks::AndTables->convert( + $html => { + leftmargin => 0, + rightmargin => 78, + no_rowspacing => 1, + before_link => '', + after_link => ' (%l)', + footnote => '', + skip_linked_urls => 1, + with_emphasis => 0, + } + ); + $text //= ''; + }; + $RT::Logger->error("Failed to downgrade HTML to plain text: $@") if $@; + return $text; +} + + RT::Base->_ImportOverlays(); 1; diff --git a/rt/lib/RT/Interface/Email.pm.orig b/rt/lib/RT/Interface/Email.pm.orig index 74120ba07..f860461e0 100755 --- a/rt/lib/RT/Interface/Email.pm.orig +++ b/rt/lib/RT/Interface/Email.pm.orig @@ -2,7 +2,7 @@ # # COPYRIGHT: # -# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC +# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC # # # (Except where explicitly superseded by other copyright notices) @@ -114,7 +114,7 @@ sub CheckForLoops { my $head = shift; # If this instance of RT sent it our, we don't want to take it in - my $RTLoop = $head->get("X-RT-Loop-Prevention") || ""; + my $RTLoop = Encode::decode( "UTF-8", $head->get("X-RT-Loop-Prevention") || "" ); chomp ($RTLoop); # remove that newline if ( $RTLoop eq RT->Config->Get('rtname') ) { return 1; @@ -253,22 +253,27 @@ sub MailError { # the colons are necessary to make ->build include non-standard headers my %entity_args = ( Type => "multipart/mixed", - From => $args{'From'}, - Bcc => $args{'Bcc'}, - To => $args{'To'}, - Subject => $args{'Subject'}, - 'X-RT-Loop-Prevention:' => RT->Config->Get('rtname'), + From => Encode::encode( "UTF-8", $args{'From'} ), + Bcc => Encode::encode( "UTF-8", $args{'Bcc'} ), + To => Encode::encode( "UTF-8", $args{'To'} ), + Subject => EncodeToMIME( String => $args{'Subject'} ), + 'X-RT-Loop-Prevention:' => Encode::encode( "UTF-8", RT->Config->Get('rtname') ), ); # only set precedence if the sysadmin wants us to if (defined(RT->Config->Get('DefaultErrorMailPrecedence'))) { - $entity_args{'Precedence:'} = RT->Config->Get('DefaultErrorMailPrecedence'); + $entity_args{'Precedence:'} = + Encode::encode( "UTF-8", RT->Config->Get('DefaultErrorMailPrecedence') ); } my $entity = MIME::Entity->build(%entity_args); SetInReplyTo( Message => $entity, InReplyTo => $args{'MIMEObj'} ); - $entity->attach( Data => $args{'Explanation'} . "\n" ); + $entity->attach( + Type => "text/plain", + Charset => "UTF-8", + Data => Encode::encode( "UTF-8", $args{'Explanation'} . "\n" ), + ); if ( $args{'MIMEObj'} ) { $args{'MIMEObj'}->sync_headers; @@ -276,7 +281,7 @@ sub MailError { } if ( $args{'Attach'} ) { - $entity->attach( Data => $args{'Attach'}, Type => 'message/rfc822' ); + $entity->attach( Data => Encode::encode( "UTF-8", $args{'Attach'} ), Type => 'message/rfc822' ); } @@ -374,7 +379,7 @@ sub SendEmail { return 0; } - my $msgid = $args{'Entity'}->head->get('Message-ID') || ''; + my $msgid = Encode::decode( "UTF-8", $args{'Entity'}->head->get('Message-ID') || '' ); chomp $msgid; # If we don't have any recipients to send to, don't send a message; @@ -411,7 +416,7 @@ sub SendEmail { require RT::Date; my $date = RT::Date->new( RT->SystemUser ); $date->SetToNow; - $args{'Entity'}->head->set( 'Date', $date->RFC2822( Timezone => 'server' ) ); + $args{'Entity'}->head->set( 'Date', Encode::encode( "UTF-8", $date->RFC2822( Timezone => 'server' ) ) ); } my $mail_command = RT->Config->Get('MailCommand'); @@ -514,12 +519,13 @@ sub SendEmail { # 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); + my @recipients = map $_->address, map + Email::Address->parse(Encode::decode("UTF-8", $head->get($_))), + qw(To Cc Bcc); $head->delete('Bcc'); my $sender = RT->Config->Get('SMTPFrom') - || $args{'Entity'}->head->get('From'); + || Encode::decode( "UTF-8", $args{'Entity'}->head->get('From') ); chomp $sender; my $status = $smtp->mail( $sender ) @@ -624,10 +630,10 @@ sub SendEmailUsingTemplate { return -1; } - $mail->head->set( $_ => Encode::encode_utf8( $args{ $_ } ) ) + $mail->head->set( $_ => Encode::encode( "UTF-8", $args{ $_ } ) ) foreach grep defined $args{$_}, qw(To Cc Bcc From); - $mail->head->set( $_ => $args{ExtraHeaders}{$_} ) + $mail->head->set( $_ => Encode::encode( "UTF-8", $args{ExtraHeaders}{$_} ) ) foreach keys %{ $args{ExtraHeaders} }; SetInReplyTo( Message => $mail, InReplyTo => $args{'InReplyTo'} ); @@ -760,8 +766,9 @@ sub SendForward { . $txn->id ." of a ticket #". $txn->ObjectId; } $mail = MIME::Entity->build( - Type => 'text/plain', - Data => $description, + Type => 'text/plain', + Charset => "UTF-8", + Data => Encode::encode( "UTF-8", $description ), ); } @@ -844,7 +851,7 @@ sub SignEncrypt { ); return 1 unless $args{'Sign'} || $args{'Encrypt'}; - my $msgid = $args{'Entity'}->head->get('Message-ID') || ''; + my $msgid = Encode::decode( "UTF-8", $args{'Entity'}->head->get('Message-ID') || '' ); chomp $msgid; $RT::Logger->debug("$msgid Signing message") if $args{'Sign'}; @@ -980,9 +987,6 @@ sub EncodeToMIME { $value =~ s/\s+$//; - # 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, '' ); @@ -1087,7 +1091,8 @@ sub ParseCcAddressesFromHead { && !IgnoreCcAddress( $_ ) } map lc $user->CanonicalizeEmailAddress( $_->address ), - map RT::EmailParser->CleanupAddresses( Email::Address->parse( $args{'Head'}->get( $_ ) ) ), + map RT::EmailParser->CleanupAddresses( Email::Address->parse( + Encode::decode( "UTF-8", $args{'Head'}->get( $_ ) ) ) ), qw(To Cc); } @@ -1125,7 +1130,7 @@ sub ParseSenderAddressFromHead { #Figure out who's sending this message. foreach my $header ( @sender_headers ) { - my $addr_line = $head->get($header) || next; + my $addr_line = Encode::decode( "UTF-8", $head->get($header) ) || next; my ($addr, $name) = ParseAddressFromHeader( $addr_line ); # only return if the address is not empty return ($addr, $name, @errors) if $addr; @@ -1153,7 +1158,7 @@ sub ParseErrorsToAddressFromHead { foreach my $header ( 'Errors-To', 'Reply-To', 'From', 'Sender' ) { # If there's a header of that name - my $headerobj = $head->get($header); + my $headerobj = Encode::decode( "UTF-8", $head->get($header) ); if ($headerobj) { my ( $addr, $name ) = ParseAddressFromHeader($headerobj); @@ -1198,9 +1203,9 @@ sub DeleteRecipientsFromHead { my %skip = map { lc $_ => 1 } @_; foreach my $field ( qw(To Cc Bcc) ) { - $head->set( $field => + $head->set( $field => Encode::encode( "UTF-8", join ', ', map $_->format, grep !$skip{ lc $_->address }, - Email::Address->parse( $head->get( $field ) ) + Email::Address->parse( Encode::decode( "UTF-8", $head->get( $field ) ) ) ) ); } } @@ -1233,7 +1238,7 @@ sub SetInReplyTo { my $get_header = sub { my @res; if ( $args{'InReplyTo'}->isa('MIME::Entity') ) { - @res = $args{'InReplyTo'}->head->get( shift ); + @res = map {Encode::decode("UTF-8", $_)} $args{'InReplyTo'}->head->get( shift ); } else { @res = $args{'InReplyTo'}->GetHeader( shift ) || ''; } @@ -1256,14 +1261,14 @@ sub SetInReplyTo { if @references > 10; my $mail = $args{'Message'}; - $mail->head->set( 'In-Reply-To' => Encode::encode_utf8(join ' ', @rtid? (@rtid) : (@id)) ) if @id || @rtid; - $mail->head->set( 'References' => Encode::encode_utf8(join ' ', @references) ); + $mail->head->set( 'In-Reply-To' => Encode::encode( "UTF-8", join ' ', @rtid? (@rtid) : (@id)) ) if @id || @rtid; + $mail->head->set( 'References' => Encode::encode( "UTF-8", join ' ', @references) ); } sub ExtractTicketId { my $entity = shift; - my $subject = $entity->head->get('Subject') || ''; + my $subject = Encode::decode( "UTF-8", $entity->head->get('Subject') || '' ); chomp $subject; return ParseTicketId( $subject ); } @@ -1468,14 +1473,14 @@ sub Gateway { my $head = $Message->head; my $ErrorsTo = ParseErrorsToAddressFromHead( $head ); my $Sender = (ParseSenderAddressFromHead( $head ))[0]; - my $From = $head->get("From"); + my $From = Encode::decode( "UTF-8", $head->get("From") ); chomp $From if defined $From; - my $MessageId = $head->get('Message-ID') + my $MessageId = Encode::decode( "UTF-8", $head->get('Message-ID') ) || "Config->Get('Organization') .'>'; #Pull apart the subject line - my $Subject = $head->get('Subject') || ''; + my $Subject = Encode::decode( "UTF-8", $head->get('Subject') || ''); chomp $Subject; # Lets check for mail loops of various sorts. @@ -1498,7 +1503,7 @@ sub Gateway { $args{'ticket'} ||= ExtractTicketId( $Message ); # ExtractTicketId may have been overridden, and edited the Subject - my $NewSubject = $Message->head->get('Subject'); + my $NewSubject = Encode::decode( "UTF-8", $Message->head->get('Subject') ); chomp $NewSubject; $SystemTicket = RT::Ticket->new( RT->SystemUser ); @@ -1746,7 +1751,7 @@ sub _RunUnsafeAction { @_ ); - my $From = $args{Message}->head->get("From"); + my $From = Encode::decode( "UTF-8", $args{Message}->head->get("From") ); if ( $args{'Action'} =~ /^take$/i ) { my ( $status, $msg ) = $args{'Ticket'}->SetOwner( $args{'CurrentUser'}->id ); @@ -1902,7 +1907,7 @@ sub _HandleMachineGeneratedMail { # to the scrip. We might want to notify nobody. Or just # the RT Owner. Or maybe all Privileged watchers. my ( $Sender, $junk ) = ParseSenderAddressFromHead($head); - $head->replace( 'RT-Squelch-Replies-To', $Sender ); + $head->replace( 'RT-Squelch-Replies-To', Encode::encode("UTF-8", $Sender ) ); $head->replace( 'RT-DetectedAutoGenerated', 'true' ); } return ( 1, $ErrorsTo, "Handled machine detection", $IsALoop ); diff --git a/rt/lib/RT/Interface/Email/Auth/Crypt.pm b/rt/lib/RT/Interface/Email/Auth/Crypt.pm new file mode 100644 index 000000000..8f8e636dc --- /dev/null +++ b/rt/lib/RT/Interface/Email/Auth/Crypt.pm @@ -0,0 +1,294 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC +# +# +# (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::Email::Auth::Crypt; + +use strict; +use warnings; + +=head1 NAME + +RT::Interface::Email::Auth::Crypt - decrypting and verifying protected emails + +=head2 DESCRIPTION + +This mail plugin decrypts and verifies incoming emails. Supported +encryption protocols are GnuPG and SMIME. + +This code is independant from code that encrypts/sign outgoing emails, so +it's possible to decrypt data without bringing in encryption. To enable +it put the module in the mail plugins list: + + Set(@MailPlugins, 'Auth::MailFrom', 'Auth::Crypt', ...other filters...); + +C will not function without C listed before +it. + +=head3 GnuPG + +To use the gnupg-secured mail gateway, you need to do the following: + +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(%GnuPGOptions, homedir => '/opt/rt4/var/data/GnuPG'); + +Read also: L and L. + +=head3 SMIME + +To use the SMIME-secured mail gateway, you need to do the following: + +Set up a SMIME key directory with files containing keys for queues' +addresses and specify the following in your SiteConfig.pm + + Set(%SMIME, + Enable => 1, + OpenSSL => '/usr/bin/openssl', + Keyring => '/opt/rt4/var/data/smime', + CAPath => '/opt/rt4/var/data/smime/signing-ca.pem', + Passphrase => { + 'queue.address@example.com' => 'passphrase', + '' => 'fallback', + }, + ); + +Read also: L and L. + +=cut + +sub ApplyBeforeDecode { return 1 } + +use RT::Crypt; +use RT::EmailParser (); + +sub GetCurrentUser { + my %args = ( + Message => undef, + RawMessageRef => undef, + Queue => undef, + Actions => undef, + @_ + ); + + # we clean all possible headers + my @headers = + qw( + X-RT-Incoming-Encryption + X-RT-Incoming-Signature X-RT-Privacy + X-RT-Sign X-RT-Encrypt + ), + map "X-RT-$_-Status", RT::Crypt->Protocols; + foreach my $p ( $args{'Message'}->parts_DFS ) { + $p->head->delete($_) for @headers; + } + + my (@res) = RT::Crypt->VerifyDecrypt( + %args, + Entity => $args{'Message'}, + ); + if ( !@res ) { + if (RT->Config->Get('Crypt')->{'RejectOnUnencrypted'}) { + EmailErrorToSender( + %args, + Template => 'Error: unencrypted message', + Arguments => { Message => $args{'Message'} }, + ); + return (-1, 'rejected because the message is unencrypted with RejectOnUnencrypted enabled'); + } + else { + $args{'Message'}->head->replace( + 'X-RT-Incoming-Encryption' => 'Not encrypted' + ); + } + return 1; + } + + if ( grep {$_->{'exit_code'}} @res ) { + my @fail = grep {$_->{status}{Status} ne "DONE"} + map { my %ret = %{$_}; map {+{%ret, status => $_}} RT::Crypt->ParseStatus( Protocol => $_->{Protocol}, Status => $_->{status})} + @res; + for my $fail ( @fail ) { + $RT::Logger->warning("Failure during ".$fail->{Protocol}." ". lc($fail->{status}{Operation}) . ": ". $fail->{status}{Message}); + } + 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'} }, + ); + + my @found; + my @check_protocols = RT::Crypt->EnabledOnIncoming; + foreach my $part ( $args{'Message'}->parts_DFS ) { + my $decrypted; + + foreach my $protocol ( @check_protocols ) { + my @status = grep defined && length, + map Encode::decode( "UTF-8", $_), $part->head->get( "X-RT-$protocol-Status" ); + next unless @status; + + push @found, $protocol; + + for ( map RT::Crypt->ParseStatus( Protocol => $protocol, Status => "$_" ), @status ) { + if ( $_->{Operation} eq 'Decrypt' && $_->{Status} eq 'DONE' ) { + $decrypted = 1; + } + if ( $_->{Operation} eq 'Verify' && $_->{Status} eq 'DONE' ) { + $part->head->replace( + 'X-RT-Incoming-Signature' => Encode::encode( "UTF-8", $_->{UserString} ) + ); + } + } + } + + $part->head->replace( + 'X-RT-Incoming-Encryption' => + $decrypted ? 'Success' : 'Not encrypted' + ); + } + + my %seen; + $args{'Message'}->head->replace( 'X-RT-Privacy' => Encode::encode( "UTF-8", $_ ) ) + foreach grep !$seen{$_}++, @found; + + return 1; +} + +sub HandleErrors { + my %args = ( + Message => undef, + Result => [], + @_ + ); + + my $reject = 0; + + my %sent_once = (); + foreach my $run ( @{ $args{'Result'} } ) { + my @status = RT::Crypt->ParseStatus( Protocol => $run->{'Protocol'}, Status => $run->{'status'} ); + unless ( $sent_once{'NoPrivateKey'} ) { + unless ( CheckNoPrivateKey( Message => $args{'Message'}, Status => \@status ) ) { + $sent_once{'NoPrivateKey'}++; + $reject = 1 if RT->Config->Get('Crypt')->{'RejectOnMissingPrivateKey'}; + } + } + unless ( $sent_once{'BadData'} ) { + unless ( CheckBadData( Message => $args{'Message'}, Status => \@status ) ) { + $sent_once{'BadData'}++; + $reject = 1 if RT->Config->Get('Crypt')->{'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"); + + return EmailErrorToSender( + %args, + Template => 'Error: no private key', + Arguments => { Message => $args{'Message'} }, + ); +} + +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; + + return EmailErrorToSender( + %args, + Template => 'Error: bad encrypted data', + Arguments => { Messages => [ @bad_data_messages ] }, + ); +} + +sub EmailErrorToSender { + my %args = (@_); + + $args{'Arguments'} ||= {}; + $args{'Arguments'}{'TicketObj'} ||= $args{'Ticket'}; + + my $address = (RT::Interface::Email::ParseSenderAddressFromHead( $args{'Message'}->head ))[0]; + my ($status) = RT::Interface::Email::SendEmailUsingTemplate( + To => $address, + Template => $args{'Template'}, + Arguments => $args{'Arguments'}, + InReplyTo => $args{'Message'}, + ); + unless ( $status ) { + $RT::Logger->error("Couldn't send '$args{'Template'}''"); + } + return 0; +} + +RT::Base->_ImportOverlays(); + +1; diff --git a/rt/lib/RT/Interface/Email/Auth/GnuPG.pm b/rt/lib/RT/Interface/Email/Auth/GnuPG.pm deleted file mode 100755 index ec409a41e..000000000 --- a/rt/lib/RT/Interface/Email/Auth/GnuPG.pm +++ /dev/null @@ -1,257 +0,0 @@ -# BEGIN BPS TAGGED BLOCK {{{ -# -# COPYRIGHT: -# -# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC -# -# -# (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::Email::Auth::GnuPG; - -use strict; -use warnings; - -=head2 GetCurrentUser - -To use the gnupg-secured mail gateway, you need to do the following: - -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(%GnuPGOptions, homedir => '/opt/rt4/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, - @_ - ); - - foreach my $p ( $args{'Message'}->parts_DFS ) { - $p->head->delete($_) for qw( - X-RT-GnuPG-Status X-RT-Incoming-Encryption - X-RT-Incoming-Signature X-RT-Privacy - X-RT-Sign X-RT-Encrypt - ); - } - - my $msg = $args{'Message'}->dup; - - my ($status, @res) = VerifyDecrypt( - Entity => $args{'Message'}, AddStatus => 1, - ); - if ( $status && !@res ) { - $args{'Message'}->head->replace( - '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->replace( 'X-RT-Privacy' => 'PGP' ); - - foreach my $part ( $args{'Message'}->parts_DFS ) { - my $decrypted; - - my $status = Encode::decode( "UTF-8", $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->replace( - 'X-RT-Incoming-Signature' => Encode::encode( "UTF-8", $_->{UserString} ) - ); - } - } - } - - $part->head->replace( - 'X-RT-Incoming-Encryption' => - $decrypted ? 'Success' : 'Not encrypted' - ); - } - - return 1; -} - -sub HandleErrors { - my %args = ( - Message => undef, - Result => [], - @_ - ); - - my $reject = 0; - - 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; -} - -RT::Base->_ImportOverlays(); - -1; - diff --git a/rt/lib/RT/Interface/REST.pm b/rt/lib/RT/Interface/REST.pm index edfc5d3db..b6f9fd35f 100644 --- a/rt/lib/RT/Interface/REST.pm +++ b/rt/lib/RT/Interface/REST.pm @@ -283,17 +283,52 @@ sub vpush { # "Normalise" a hash key that's known to be multi-valued. sub vsplit { - my ($val) = @_; + my ($val, $strip) = @_; my @words; - - foreach my $line (map {split /\n/} (ref $val eq 'ARRAY') ? @$val : ($val||'')) - { - # XXX: This should become a real parser, ? la Text::ParseWords. - $line =~ s/^\s+//; - $line =~ s/\s+$//; - push @words, split /\s*,\s*/, $line; + my @values = map {split /\n/} (ref $val eq 'ARRAY' ? @$val : $val); + + foreach my $line (@values) { + while ($line =~ /\S/) { + $line =~ s/^ + \s* # Trim leading whitespace + (?: + (") # Quoted string + ((?>[^\\"]*(?:\\.[^\\"]*)*))" + | + (') # Single-quoted string + ((?>[^\\']*(?:\\.[^\\']*)*))' + | + q\{(.*?)\} # A perl-ish q{} string; this does + # no paren balancing, however, and + # only exists for back-compat + | + (.*?) # Anything else, until the next comma + ) + \s* # Trim trailing whitespace + (?: + \Z # Finish at end-of-line + | + , # Or a comma + ) + //xs or last; # There should be no way this match + # fails, but add a failsafe to + # prevent infinite-looping if it + # somehow does. + my ($quote, $quoted) = ($1 ? ($1, $2) : $3 ? ($3, $4) : ('', $5 || $6)); + # Only unquote the quote character, or the backslash -- and + # only if we were originally quoted.. + if ($5) { + $quoted =~ s/([\\'])/\\$1/g; + $quote = "'"; + } + if ($strip) { + $quoted =~ s/\\([\\$quote])/$1/g if $quote; + push @words, $quoted; + } else { + push @words, "$quote$quoted$quote"; + } + } } - return \@words; } @@ -324,11 +359,17 @@ sub process_attachments { } my $info = $cgi->uploadInfo($fh); + # If Content-ID exists for attachment then we need multipart/related + # to be able to refer to this Content-Id in core of mime message + if($info->{'Content-ID'}) { + $entity->head->set('Content-Type', 'multipart/related'); + } my $new_entity = $entity->attach( Path => $tmp_fn, Type => $info->{'Content-Type'} || guess_media_type($tmp_fn), Filename => $file, Disposition => $info->{'Content-Disposition'} || "attachment", + 'Content-ID' => $info->{'Content-ID'}, ); $new_entity->bodyhandle->{'_dirty_hack_to_save_a_ref_tmp_fh'} = $tmp_fh; $i++; diff --git a/rt/lib/RT/Interface/Web.pm b/rt/lib/RT/Interface/Web.pm index 4cf3a0726..dad6a8ee4 100644 --- a/rt/lib/RT/Interface/Web.pm +++ b/rt/lib/RT/Interface/Web.pm @@ -70,6 +70,7 @@ use RT::Interface::Web::Session; use Digest::MD5 (); use List::MoreUtils qw(); use JSON qw(); +use Plack::Util; =head2 SquishedCSS $style @@ -99,6 +100,37 @@ sub SquishedJS { return $js; } +=head2 JSFiles + +=cut + +sub JSFiles { + return qw{ + jquery-1.9.1.min.js + jquery_noconflict.js + jquery-ui-1.10.0.custom.min.js + jquery-ui-timepicker-addon.js + jquery-ui-patch-datepicker.js + jquery.modal.min.js + jquery.modal-defaults.js + jquery.cookie.js + titlebox-state.js + i18n.js + util.js + autocomplete.js + jquery.event.hover-1.0.js + superfish.js + supersubs.js + jquery.supposition.js + history-folding.js + cascaded.js + forms.js + event-registration.js + late.js + /static/RichText/ckeditor.js + }, RT->Config->Get('JSFiles'); +} + =head2 ClearSquished Removes the cached CSS and JS entries, forcing them to be regenerated @@ -111,13 +143,13 @@ sub ClearSquished { %SQUISHED_CSS = (); } -=head2 EscapeUTF8 SCALARREF +=head2 EscapeHTML SCALARREF does a css-busting but minimalist escaping of whatever html you're passing in. =cut -sub EscapeUTF8 { +sub EscapeHTML { my $ref = shift; return unless defined $$ref; @@ -130,7 +162,15 @@ sub EscapeUTF8 { $$ref =~ s/'/'/g; } - +# Back-compat +# XXX: Remove in 4.4 +sub EscapeUTF8 { + RT->Deprecated( + Instead => "EscapeHTML", + Remove => "4.4", + ); + EscapeHTML(@_); +} =head2 EscapeURI SCALARREF @@ -148,13 +188,15 @@ sub EscapeURI { =head2 EncodeJSON SCALAR -Encodes the SCALAR to JSON and returns a JSON string. SCALAR may be a simple -value or a reference. +Encodes the SCALAR to JSON and returns a JSON Unicode (B UTF-8) string. +SCALAR may be a simple value or a reference. =cut sub EncodeJSON { - JSON::to_json(shift, { utf8 => 1, allow_nonref => 1 }); + my $s = JSON::to_json(shift, { allow_nonref => 1 }); + $s =~ s{/}{\\/}g; + return $s; } sub _encode_surrogates { @@ -190,36 +232,29 @@ sub WebCanonicalizeInfo { -=head2 WebExternalAutoInfo($user); +=head2 WebRemoteUserAutocreateInfo($user); -Returns a hash of user attributes, used when WebExternalAuto is set. +Returns a hash of user attributes, used when WebRemoteUserAutocreate is set. =cut -sub WebExternalAutoInfo { +sub WebRemoteUserAutocreateInfo { my $user = shift; my %user_info; # 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::UserAutocreateDefaultsOnLogin + || ( ref($RT::UserAutocreateDefaultsOnLogin) && not exists $RT::UserAutocreateDefaultsOnLogin->{Privileged} ) ) { $user_info{'Privileged'} = 1; } - if ( $^O !~ /^(?:riscos|MacOS|MSWin32|dos|os2)$/ ) { - - # Populate fields with information from Unix /etc/passwd - - 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' ) { - - # Populate fields with information from NT domain controller - } + # Populate fields with information from Unix /etc/passwd + my ( $comments, $realname ) = ( getpwnam($user) )[ 5, 6 ]; + $user_info{'Comments'} = $comments if defined $comments; + $user_info{'RealName'} = $realname if defined $realname; # and return the wad of stuff return {%user_info}; @@ -278,7 +313,7 @@ sub HandleRequest { MaybeShowNoAuthPage($ARGS); - AttemptExternalAuth($ARGS) if RT->Config->Get('WebExternalAuthContinuous') or not _UserLoggedIn(); + AttemptExternalAuth($ARGS) if RT->Config->Get('WebRemoteUserContinuous') or not _UserLoggedIn(); _ForceLogout() unless _UserLoggedIn(); @@ -300,7 +335,7 @@ sub HandleRequest { # REST urls get a special 401 response if ($m->request_comp->path =~ m{^/REST/\d+\.\d+/}) { - $HTML::Mason::Commands::r->content_type("text/plain"); + $HTML::Mason::Commands::r->content_type("text/plain; charset=utf-8"); $m->error_format("text"); $m->out("RT/$RT::VERSION 401 Credentials required\n"); $m->out("\n$msg\n") if $msg; @@ -441,6 +476,18 @@ params. =cut sub TangentForLogin { + my $login = TangentForLoginURL(@_); + Redirect( RT->Config->Get('WebBaseURL') . $login ); +} + +=head2 TangentForLoginURL [HASH] + +Returns a URL suitable for tangenting for login. Optionally takes a hash which +is dumped into query params. + +=cut + +sub TangentForLoginURL { my $ARGS = shift; my $hash = SetNextPage($ARGS); my %query = (@_, next => $hash); @@ -448,9 +495,9 @@ sub TangentForLogin { $query{mobile} = 1 if $HTML::Mason::Commands::m->request_comp->path =~ m{^/m(/|$)}; - my $login = RT->Config->Get('WebURL') . 'NoAuth/Login.html?'; + my $login = RT->Config->Get('WebPath') . '/NoAuth/Login.html?'; $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query); - Redirect($login); + return $login; } =head2 TangentForLoginWithError ERROR @@ -645,24 +692,24 @@ sub ShowRequestedPage { sub AttemptExternalAuth { my $ARGS = shift; - return unless ( RT->Config->Get('WebExternalAuth') ); + return unless ( RT->Config->Get('WebRemoteUserAuth') ); 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 + my $logged_in_external_user = _UserLoggedIn() && $HTML::Mason::Commands::session{'WebExternallyAuthed'}; - # do we actually have a REMOTE_USER equivlent? - if ( RT::Interface::Web::WebCanonicalizeInfo() ) { - my $orig_user = $user; + # If RT is configured for external auth, let's go through and get REMOTE_USER + # Do we actually have a REMOTE_USER or equivalent? We only check auth if + # 1) we have no logged in user, or 2) we have a user who is externally + # authed. If we have a logged in user who is internally authed, don't + # check remote user otherwise we may log them out. + if (RT::Interface::Web::WebCanonicalizeInfo() + and (not _UserLoggedIn() or $logged_in_external_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; - } + my $load_method = RT->Config->Get('WebRemoteUserGecos') ? 'LoadByGecos' : 'Load'; my $next = RemoveNextPage($ARGS->{'next'}); $next = $next->{'url'} if ref $next; @@ -670,12 +717,12 @@ sub AttemptExternalAuth { $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new(); $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user); - if ( RT->Config->Get('WebExternalAuto') and not _UserLoggedIn() ) { + if ( RT->Config->Get('WebRemoteUserAutocreate') 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') : {} }, + %{ ref RT->Config->Get('UserAutocreateDefaultsOnLogin') ? RT->Config->Get('UserAutocreateDefaultsOnLogin') : {} }, Name => $user, Gecos => $user, ); @@ -683,10 +730,10 @@ sub AttemptExternalAuth { if ($val) { # now get user specific information, to better create our user. - my $new_user_info = RT::Interface::Web::WebExternalAutoInfo($user); + my $new_user_info = RT::Interface::Web::WebRemoteUserAutocreateInfo($user); # set the attributes that have been defined. - foreach my $attribute ( $UserObj->WritableAttributes ) { + foreach my $attribute ( $UserObj->WritableAttributes, qw(Privileged Disabled) ) { $m->callback( Attribute => $attribute, User => $user, @@ -699,19 +746,13 @@ sub AttemptExternalAuth { } $HTML::Mason::Commands::session{'CurrentUser'}->Load($user); } else { - - # we failed to successfully create the user. abort abort abort. - delete $HTML::Mason::Commands::session{'CurrentUser'}; - - if (RT->Config->Get('WebFallbackToInternalAuth')) { - TangentForLoginWithError($ARGS, 'Cannot create user: [_1]', $msg); - } else { - $m->abort(); - } + RT->Logger->error("Couldn't auto-create user '$user' when attempting WebRemoteUser: $msg"); + AbortExternalAuth( Error => "UserAutocreateDefaultsOnLogin" ); } } if ( _UserLoggedIn() ) { + $HTML::Mason::Commands::session{'WebExternallyAuthed'} = 1; $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' ); # It is possible that we did a redirect to the login page, # if the external auth allows lack of auth through with no @@ -723,28 +764,45 @@ sub AttemptExternalAuth { # straight-up external auth would always redirect to / # when you first hit it. } else { - delete $HTML::Mason::Commands::session{'CurrentUser'}; - $user = $orig_user; - - unless ( RT->Config->Get('WebFallbackToInternalAuth') ) { - TangentForLoginWithError($ARGS, 'You are not an authorized user'); - } - } - } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) { - unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) { - # XXX unreachable due to prior defaulting in HandleRequest (check c34d108) - TangentForLoginWithError($ARGS, 'You are not an authorized user'); + # Couldn't auth with the REMOTE_USER provided because an RT + # user doesn't exist and we're configured not to create one. + RT->Logger->error("Couldn't find internal user for '$user' when attempting WebRemoteUser and RT is not configured for auto-creation. Refer to `perldoc $RT::BasePath/docs/authentication.pod` if you want to allow auto-creation."); + AbortExternalAuth( + Error => "NoInternalUser", + User => $user, + ); } - } 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'}; + } + elsif ($logged_in_external_user) { + # The logged in external user was deauthed by the auth system and we + # should kick them out. + AbortExternalAuth( Error => "Deauthorized" ); + } + elsif (not RT->Config->Get('WebFallbackToRTLogin')) { + # Abort if we don't want to fallback internally + AbortExternalAuth( Error => "NoRemoteUser" ); } } +sub AbortExternalAuth { + my %args = @_; + my $error = $args{Error} ? "/Errors/WebRemoteUser/$args{Error}" : undef; + my $m = $HTML::Mason::Commands::m; + my $r = $HTML::Mason::Commands::r; + + _ForceLogout(); + + # Clear the decks, not that we should have partial content. + $m->clear_buffer; + + $r->status(403); + $m->comp($error, %args) + if $error and $m->comp_exists($error); + + # Return a 403 Forbidden or we may fallback to a login page with no form + $m->abort(403); +} + sub AttemptPasswordAuthentication { my $ARGS = shift; return unless defined $ARGS->{user} && defined $ARGS->{pass}; @@ -770,7 +828,7 @@ sub AttemptPasswordAuthentication { InstantiateNewSession(); $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj; - $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' ); + $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler', RedirectTo => \$next ); # Really the only time we don't want to redirect here is if we were # passed user and pass as query params in the URL. @@ -838,6 +896,30 @@ sub SendSessionCookie { $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string; } +=head2 GetWebURLFromRequest + +People may use different web urls instead of C<$WebURL> in config. +Return the web url current user is using. + +=cut + +sub GetWebURLFromRequest { + + my $uri = URI->new( RT->Config->Get('WebURL') ); + + if ( defined $ENV{HTTPS} and $ENV{'HTTPS'} eq 'on' ) { + $uri->scheme('https'); + } + else { + $uri->scheme('http'); + } + + # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST + $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} ); + $uri->port( $ENV{'SERVER_PORT'} ); + return "$uri"; # stringify to be consistent with WebURL in config +} + =head2 Redirect URL This routine ells the current user's browser to redirect to URL. @@ -868,15 +950,10 @@ sub Redirect { && $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'); - } - - # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST - $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'}); - $uri->port( $ENV{'SERVER_PORT'} ); + my $env_uri = URI->new(GetWebURLFromRequest()); + $uri->scheme($env_uri->scheme); + $uri->host($env_uri->host); + $uri->port($env_uri->port); } # not sure why, but on some systems without this call mason doesn't @@ -890,13 +967,13 @@ sub Redirect { $HTML::Mason::Commands::m->abort; } -=head2 CacheControlExpiresHeaders +=head2 GetStaticHeaders -set both Cache-Control and Expires http headers +return an arrayref of Headers (currently, Cache-Control and Expires). =cut -sub CacheControlExpiresHeaders { +sub GetStaticHeaders { my %args = @_; my $Visibility = 'private'; @@ -913,13 +990,28 @@ sub CacheControlExpiresHeaders { ? sprintf "max-age=%d, %s", $args{Time}, $Visibility : 'no-cache' ; - $HTML::Mason::Commands::r->headers_out->{'Cache-Control'} = $CacheControl; my $expires = RT::Date->new(RT->SystemUser); $expires->SetToNow; $expires->AddSeconds( $args{Time} ) if $args{Time}; - $HTML::Mason::Commands::r->headers_out->{'Expires'} = $expires->RFC2616; + return [ + Expires => $expires->RFC2616, + 'Cache-Control' => $CacheControl, + ]; +} + +=head2 CacheControlExpiresHeaders + +set both Cache-Control and Expires http headers + +=cut + +sub CacheControlExpiresHeaders { + Plack::Util::header_iter( GetStaticHeaders(@_), sub { + my ( $key, $val ) = @_; + $HTML::Mason::Commands::r->headers_out->{$key} = $val; + } ); } =head2 StaticFileHeaders @@ -932,20 +1024,12 @@ This routine could really use _accurate_ heuristics. (XXX TODO) =cut sub StaticFileHeaders { - my $date = RT::Date->new(RT->SystemUser); - # remove any cookie headers -- if it is cached publicly, it # shouldn't include anyone's cookie! delete $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'}; # Expire things in a month. CacheControlExpiresHeaders( Time => 'forever' ); - - # 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 - # $date->Set( Value => $^T ); - # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616; } =head2 ComponentPathIsSafe PATH @@ -1109,7 +1193,7 @@ sub StripContent { # Check for plaintext sig return '' if not $html and $content =~ /^(--)?\Q$sig\E$/; - # Check for html-formatted sig; we don't use EscapeUTF8 here + # Check for html-formatted sig; we don't use EscapeHTML here # because we want to precisely match the escapting that FCKEditor # uses. $sig =~ s/&/&/g; @@ -1270,6 +1354,16 @@ sub ComponentRoots { return @roots; } +sub StaticRoots { + my $self = shift; + my @static = ( + $RT::LocalStaticPath, + (map { $_->StaticDir } @{RT->Plugins}), + $RT::StaticPath, + ); + return grep { $_ and -d $_ } @static; +} + our %is_whitelisted_component = ( # The RSS feed embeds an auth token in the path, but query # information for the search. Because it's a straight-up read, in @@ -1284,6 +1378,7 @@ our %is_whitelisted_component = ( '/Search/Simple.html' => 1, '/m/tickets/search' => 1, '/Search/Chart.html' => 1, + '/User/Search.html' => 1, # This page takes Attachment and Transaction argument to figure # out what to show, but it's read only and will deny information if you @@ -1466,7 +1561,7 @@ sub ExpandCSRFToken { if ($data->{attach}) { my $filename = $data->{attach}{filename}; my $mime = $data->{attach}{mime}; - $HTML::Mason::Commands::session{'Attachments'}{$filename} + $HTML::Mason::Commands::session{'Attachments'}{$ARGS->{'Token'}||''}{$filename} = $mime; } @@ -1550,10 +1645,178 @@ sub PotentialPageAction { return ""; } +=head2 RewriteInlineImages PARAMHASH + +Turns C<< >> elements in HTML into working images pointing +back to RT's stored copy. + +Takes the following parameters: + +=over 4 + +=item Content + +Scalar ref of the HTML content to rewrite. Modified in place to support the +most common use-case. + +=item Attachment + +The L object from which the Content originates. + +=item Related (optional) + +Array ref of related L objects to use for C matching. + +Defaults to the result of the C method on the passed Attachment. + +=item AttachmentPath (optional) + +The base path to use when rewriting C attributes. + +Defaults to C< $WebPath/Ticket/Attachment > + +=back + +In scalar context, returns the number of elements rewritten. + +In list content, returns the attachments IDs referred to by the rewritten +elements, in the order found. There may be duplicates. + +=cut + +sub RewriteInlineImages { + my %args = ( + Content => undef, + Attachment => undef, + Related => undef, + AttachmentPath => RT->Config->Get('WebPath')."/Ticket/Attachment", + @_ + ); + + return unless defined $args{Content} + and ref $args{Content} eq 'SCALAR' + and defined $args{Attachment}; + + my $related_part = $args{Attachment}->Closest("multipart/related") + or return; + + $args{Related} ||= $related_part->Children->ItemsArrayRef; + return unless @{$args{Related}}; + + my $content = $args{'Content'}; + my @rewritten; + + require HTML::RewriteAttributes::Resources; + $$content = HTML::RewriteAttributes::Resources->rewrite($$content, sub { + my $cid = shift; + my %meta = @_; + return $cid unless lc $meta{tag} eq 'img' + and lc $meta{attr} eq 'src' + and $cid =~ s/^cid://i; + + for my $attach (@{$args{Related}}) { + if (($attach->GetHeader('Content-ID') || '') =~ /^(<)?\Q$cid\E(?(1)>)$/) { + push @rewritten, $attach->Id; + return "$args{AttachmentPath}/" . $attach->TransactionId . '/' . $attach->Id; + } + } + + # No attachments means this is a bogus CID. Just pass it through. + RT->Logger->debug(qq[Found bogus inline image src="cid:$cid"]); + return "cid:$cid"; + }); + return @rewritten; +} + +=head2 GetCustomFieldInputName(CustomField => $cf_object, Object => $object, Grouping => $grouping_name) + +Returns the standard custom field input name; this is complementary to +L. Takes the following arguments: + +=over + +=item CustomField => I object> + +Required. + +=item Object => I + +The object that the custom field is applied to; optional. If omitted, +defaults to a new object of the appropriate class for the custom field. + +=item Grouping => I + +The grouping that the custom field is being rendered in. Groupings +allow a custom field to appear in more than one location per form. + +=back + +=cut + +sub GetCustomFieldInputName { + my %args = ( + CustomField => undef, + Object => undef, + Grouping => undef, + @_, + ); + + my $name = GetCustomFieldInputNamePrefix(%args); + + if ( $args{CustomField}->Type eq 'Select' ) { + if ( $args{CustomField}->RenderType eq 'List' and $args{CustomField}->SingleValue ) { + $name .= 'Value'; + } + else { + $name .= 'Values'; + } + } + elsif ( $args{CustomField}->Type =~ /^(?:Binary|Image)$/ ) { + $name .= 'Upload'; + } + elsif ( $args{CustomField}->Type =~ /^(?:Date|DateTime|Text|Wikitext)$/ ) { + $name .= 'Values'; + } + else { + if ( $args{CustomField}->SingleValue ) { + $name .= 'Value'; + } + else { + $name .= 'Values'; + } + } + + return $name; +} + +=head2 GetCustomFieldInputNamePrefix(CustomField => $cf_object, Object => $object, Grouping => $grouping_name) + +Returns the standard custom field input name prefix(without "Value" or alike suffix) + +=cut + +sub GetCustomFieldInputNamePrefix { + my %args = ( + CustomField => undef, + Object => undef, + Grouping => undef, + @_, + ); + + my $prefix = join '-', 'Object', ref( $args{Object} ) || $args{CustomField}->ObjectTypeFromLookupType, + ( $args{Object} && $args{Object}->id ? $args{Object}->id : '' ), + 'CustomField' . ( $args{Grouping} ? ":$args{Grouping}" : '' ), + $args{CustomField}->id, ''; + + return $prefix; +} + package HTML::Mason::Commands; use vars qw/$r $m %session/; +use Scalar::Util qw(blessed); + sub Menu { return $HTML::Mason::Commands::m->notes('menu'); } @@ -1566,7 +1829,96 @@ sub PageWidgets { return $HTML::Mason::Commands::m->notes('page-widgets'); } +sub RenderMenu { + my %args = (toplevel => 1, parent_id => '', depth => 0, @_); + return unless $args{'menu'}; + + my ($menu, $depth, $toplevel, $id, $parent_id) + = @args{qw(menu depth toplevel id parent_id)}; + + my $interp = $m->interp; + my $web_path = RT->Config->Get('WebPath'); + + my $res = ''; + $res .= ' ' x $depth; + $res .= 'apply_escapes($id, 'h') .'"' + if $id; + $res .= ' class="toplevel"' if $toplevel; + $res .= ">\n"; + + for my $child ($menu->children) { + $res .= ' 'x ($depth+1); + + my $item_id = lc(($parent_id? "$parent_id-" : "") .$child->key); + $item_id =~ s/\s/-/g; + my $eitem_id = $interp->apply_escapes($item_id, 'h'); + $res .= qq{
  • has_children; + push @classes, 'active' if $child->active; + $res .= ' class="'. join( ' ', @classes ) .'"' + if @classes; + + $res .= '>'; + + if ( my $tmp = $child->raw_html ) { + $res .= $tmp; + } else { + $res .= qq{path; + my $url = (not $path or $path =~ m{^\w+:/}) ? $path : $web_path . $path; + $url ||= "#"; + $res .= ' href="'. $interp->apply_escapes($url, 'h') .'"'; + + if ( $tmp = $child->target ) { + $res .= ' target="'. $interp->apply_escapes($tmp, 'h') .'"' + } + + if ($child->attributes) { + for my $key (keys %{$child->attributes}) { + my ($name, $value) = map { $interp->apply_escapes($_, 'h') } + $key, $child->attributes->{$key}; + $res .= " $name=\"$value\""; + } + } + $res .= '>'; + + if ( $child->escape_title ) { + $res .= $interp->apply_escapes($child->title, 'h'); + } else { + $res .= $child->title; + } + $res .= ''; + } + + if ( $child->has_children ) { + $res .= "\n"; + $res .= RenderMenu( + menu => $child, + toplevel => 0, + parent_id => $item_id, + depth => $depth+1, + return => 1, + ); + $res .= "\n"; + $res .= ' ' x ($depth+1); + } + $res .= "
  • \n"; + } + $res .= ' ' x $depth; + $res .= ''; + return $res if $args{'return'}; + $m->print($res); + return ''; +} =head2 loc ARRAY @@ -1725,9 +2077,10 @@ sub CreateTicket { my (@Actions); - my $Ticket = RT::Ticket->new( $session{'CurrentUser'} ); + my $current_user = $session{'CurrentUser'}; + my $Ticket = RT::Ticket->new( $current_user ); - my $Queue = RT::Queue->new( $session{'CurrentUser'} ); + my $Queue = RT::Queue->new( $current_user ); unless ( $Queue->Load( $ARGS{'Queue'} ) ) { Abort('Queue not found'); } @@ -1738,12 +2091,12 @@ sub CreateTicket { my $due; if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) { - $due = RT::Date->new( $session{'CurrentUser'} ); + $due = RT::Date->new( $current_user ); $due->Set( Format => 'unknown', Value => $ARGS{'Due'} ); } my $starts; if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) { - $starts = RT::Date->new( $session{'CurrentUser'} ); + $starts = RT::Date->new( $current_user ); $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} ); } @@ -1751,34 +2104,44 @@ sub CreateTicket { Content => $ARGS{Content}, ContentType => $ARGS{ContentType}, StripSignature => 1, - CurrentUser => $session{'CurrentUser'}, + CurrentUser => $current_user, ); + my $date_now = RT::Date->new( $current_user ); + $date_now->SetToNow; my $MIMEObj = MakeMIMEEntity( Subject => $ARGS{'Subject'}, - From => $ARGS{'From'}, + From => $ARGS{'From'} || $current_user->EmailAddress, + To => $ARGS{'To'} || $Queue->CorrespondAddress + || RT->Config->Get('CorrespondAddress'), Cc => $ARGS{'Cc'}, + Date => $date_now->RFC2822(Timezone => 'user'), Body => $sigless, Type => $ARGS{'ContentType'}, Interface => RT::Interface::Web::MobileClient() ? 'Mobile' : 'Web', ); - if ( $ARGS{'Attachments'} ) { - my $rv = $MIMEObj->make_multipart; - $RT::Logger->error("Couldn't make multipart message") - if !$rv || $rv !~ /^(?:DONE|ALREADY)$/; + my @attachments; + if ( my $tmp = $session{'Attachments'}{ $ARGS{'Token'} || '' } ) { + push @attachments, grep $_, map $tmp->{$_}, sort keys %$tmp; - foreach ( map $ARGS{Attachments}->{$_}, sort keys %{ $ARGS{'Attachments'} } ) { - unless ($_) { - $RT::Logger->error("Couldn't add empty attachemnt"); - next; - } - $MIMEObj->add_part($_); - } + delete $session{'Attachments'}{ $ARGS{'Token'} || '' } + unless $ARGS{'KeepAttachments'}; + $session{'Attachments'} = $session{'Attachments'} + if @attachments; + } + if ( $ARGS{'Attachments'} ) { + push @attachments, grep $_, map $ARGS{Attachments}->{$_}, sort keys %{ $ARGS{'Attachments'} }; + } + if ( @attachments ) { + $MIMEObj->make_multipart; + $MIMEObj->add_part( $_ ) foreach @attachments; } for my $argument (qw(Encrypt Sign)) { - $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 ); + if ( defined $ARGS{ $argument } ) { + $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 ); + } } my %create_args = ( @@ -1799,16 +2162,25 @@ sub CreateTicket { Status => $ARGS{'Status'}, Due => $due ? $due->ISO : undef, Starts => $starts ? $starts->ISO : undef, - MIMEObj => $MIMEObj + MIMEObj => $MIMEObj, + SquelchMailTo => $ARGS{'SquelchMailTo'}, + TransSquelchMailTo => $ARGS{'TransSquelchMailTo'}, ); - my @txn_squelch; - foreach my $type (qw(Requestor Cc AdminCc)) { - push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$type} ) - if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] }; + if ($ARGS{'DryRun'}) { + $create_args{DryRun} = 1; + $create_args{Owner} ||= $RT::Nobody->Id; + $create_args{Requestor} ||= $session{CurrentUser}->EmailAddress; + $create_args{Subject} ||= ''; + $create_args{Status} ||= $Queue->Lifecycle->DefaultOnCreate, + } else { + my @txn_squelch; + foreach my $type (qw(Requestor Cc AdminCc)) { + push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$type} ) + if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] }; + } + push @{$create_args{TransSquelchMailTo}}, @txn_squelch; } - $create_args{TransSquelchMailTo} = \@txn_squelch - if @txn_squelch; if ( $ARGS{'AttachTickets'} ) { require RT::Action::SendEmail; @@ -1818,69 +2190,16 @@ sub CreateTicket { : ( $ARGS{'AttachTickets'} ) ); } - foreach my $arg ( keys %ARGS ) { - next if $arg =~ /-(?:Magic|Category)$/; - - if ( $arg =~ /^Object-RT::Transaction--CustomField-/ ) { - $create_args{$arg} = $ARGS{$arg}; - } - - # Object-RT::Ticket--CustomField-3-Values - elsif ( $arg =~ /^Object-RT::Ticket--CustomField-(\d+)/ ) { - my $cfid = $1; - - my $cf = RT::CustomField->new( $session{'CurrentUser'} ); - $cf->SetContextObject( $Queue ); - $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); - next; - } - - my $type = $cf->Type; - - 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; - - $create_args{"CustomField-$cfid"} = \@values; - } - } - - # 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', + my %cfs = ProcessObjectCustomFieldUpdatesForCreate( + ARGSRef => \%ARGS, + ContextObject => $Queue, ); - foreach my $key ( keys %map ) { - next unless $ARGS{$key}; - $create_args{ $map{$key} } = [ grep $_, split ' ', $ARGS{$key} ]; - } + my %links = ProcessLinksForCreate( ARGSRef => \%ARGS ); + + my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args, %links, %cfs); + return $Trans if $ARGS{DryRun}; - my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args); unless ($id) { Abort($ErrMsg); } @@ -1948,10 +2267,18 @@ sub ProcessUpdateMessage { @_ ); - if ( $args{ARGSRef}->{'UpdateAttachments'} - && !keys %{ $args{ARGSRef}->{'UpdateAttachments'} } ) - { - delete $args{ARGSRef}->{'UpdateAttachments'}; + my @attachments; + if ( my $tmp = $session{'Attachments'}{ $args{'ARGSRef'}{'Token'} || '' } ) { + push @attachments, grep $_, map $tmp->{$_}, sort keys %$tmp; + + delete $session{'Attachments'}{ $args{'ARGSRef'}{'Token'} || '' } + unless $args{'KeepAttachments'}; + $session{'Attachments'} = $session{'Attachments'} + if @attachments; + } + if ( $args{ARGSRef}{'UpdateAttachments'} ) { + push @attachments, grep $_, map $args{ARGSRef}->{UpdateAttachments}{$_}, + sort keys %{ $args{ARGSRef}->{'UpdateAttachments'} }; } # Strip the signature @@ -1973,7 +2300,7 @@ sub ProcessUpdateMessage { # If, after stripping the signature, we have no message, create a # Touch transaction if necessary - if ( not $args{ARGSRef}->{'UpdateAttachments'} + if ( not @attachments and not length $args{ARGSRef}->{'UpdateContent'} ) { #if ( $args{ARGSRef}->{'UpdateTimeWorked'} ) { @@ -1993,7 +2320,7 @@ sub ProcessUpdateMessage { return; } - if ( $args{ARGSRef}->{'UpdateSubject'} eq ($args{'TicketObj'}->Subject || '') ) { + if ( ($args{ARGSRef}->{'UpdateSubject'}||'') eq ($args{'TicketObj'}->Subject || '') ) { $args{ARGSRef}->{'UpdateSubject'} = undef; } @@ -2017,14 +2344,14 @@ sub ProcessUpdateMessage { if ( my $msg = $old_txn->Message->First ) { RT::Interface::Email::SetInReplyTo( Message => $Message, - InReplyTo => $msg + InReplyTo => $msg, + Ticket => $args{'TicketObj'}, ); } - if ( $args{ARGSRef}->{'UpdateAttachments'} ) { + if ( @attachments ) { $Message->make_multipart; - $Message->add_part($_) foreach map $args{ARGSRef}->{UpdateAttachments}{$_}, - sort keys %{ $args{ARGSRef}->{'UpdateAttachments'} }; + $Message->add_part( $_ ) foreach @attachments; } if ( $args{ARGSRef}->{'AttachTickets'} ) { @@ -2036,8 +2363,8 @@ sub ProcessUpdateMessage { } my %message_args = ( - Sign => ( $args{ARGSRef}->{'Sign'} ? 1 : 0 ), - Encrypt => ( $args{ARGSRef}->{'Encrypt'} ? 1 : 0 ), + Sign => $args{ARGSRef}->{'Sign'}, + Encrypt => $args{ARGSRef}->{'Encrypt'}, MIMEObj => $Message, TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'}, CustomFields => \%txn_customfields, @@ -2052,11 +2379,11 @@ sub ProcessUpdateMessage { if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) { my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment(%message_args); push( @results, $Description ); - $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object; + $Object->UpdateCustomFields( %{ $args{ARGSRef} } ) if $Object; } 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; + $Object->UpdateCustomFields( %{ $args{ARGSRef} } ) if $Object; } else { push( @results, loc("Update type was neither correspondence nor comment.") . " " . loc("Update not recorded.") ); @@ -2115,36 +2442,39 @@ sub _ProcessUpdateMessageRecipients { sub ProcessAttachments { my %args = ( ARGSRef => {}, + Token => '', @_ ); - my $ARGSRef = $args{ARGSRef} || {}; + my $token = $args{'ARGSRef'}{'Token'} + ||= $args{'Token'} ||= Digest::MD5::md5_hex( rand(1024) ); + + my $update_session = 0; + # deal with deleting uploaded attachments - foreach my $key ( keys %$ARGSRef ) { - if ( $key =~ m/^DeleteAttach-(.+)$/ ) { - delete $session{'Attachments'}{$1}; - } - $session{'Attachments'} = { %{ $session{'Attachments'} || {} } }; + if ( my $del = $args{'ARGSRef'}{'DeleteAttach'} ) { + delete $session{'Attachments'}{ $token }{ $_ } + foreach ref $del? @$del : ($del); + + $update_session = 1; } # store the uploaded attachment in session - if ( defined $ARGSRef->{'Attach'} && length $ARGSRef->{'Attach'} ) - { # attachment? - my $attachment = MakeMIMEEntity( AttachmentFieldName => 'Attach' ); + my $new = $args{'ARGSRef'}{'Attach'}; + if ( defined $new && length $new ) { + my $attachment = MakeMIMEEntity( + AttachmentFieldName => 'Attach' + ); # This needs to be decoded because the value is a reference; # hence it was not decoded along with all of the standard # arguments in DecodeARGS - my $file_path = Encode::decode("UTF-8", "$ARGSRef->{'Attach'}"); - $session{'Attachments'} = - { %{ $session{'Attachments'} || {} }, $file_path => $attachment, }; - } + my $file_path = Encode::decode( "UTF-8", "$new"); + $session{'Attachments'}{ $token }{ $file_path } = $attachment; - # delete temporary storage entry to make WebUI clean - unless ( keys %{ $session{'Attachments'} } and $ARGSRef->{'UpdateAttach'} ) - { - delete $session{'Attachments'}; + $update_session = 1; } + $session{'Attachments'} = $session{'Attachments'} if $update_session; } @@ -2176,7 +2506,7 @@ sub MakeMIMEEntity { "Message-Id" => Encode::encode( "UTF-8", RT::Interface::Email::GenMessageId ), "X-RT-Interface" => $args{Interface}, map { $_ => Encode::encode( "UTF-8", $args{ $_} ) } - grep defined $args{$_}, qw(Subject From Cc) + grep defined $args{$_}, qw(Subject From Cc To Date) ); if ( defined $args{'Body'} && length $args{'Body'} ) { @@ -2213,7 +2543,7 @@ sub MakeMIMEEntity { Data => \@content, # Bytes, as read directly from the file, above ); if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) { - $Message->head->set( 'Subject' => Encode::encode( "UTF-8", $filename ) ); + $Message->head->replace( 'Subject' => Encode::encode( "UTF-8", $filename ) ); } # Attachment parts really shouldn't get a Message-ID or "interface" @@ -2277,7 +2607,7 @@ sub ProcessACLChanges { my $obj; if ( $object_type eq 'RT::System' ) { $obj = $RT::System; - } elsif ( $RT::ACE::OBJECT_TYPES{$object_type} ) { + } elsif ( $object_type->DOES('RT::Record::Role::Rights') ) { $obj = $object_type->new( $session{'CurrentUser'} ); $obj->Load($object_id); unless ( $obj->id ) { @@ -2377,7 +2707,7 @@ sub ProcessACLs { my $obj; if ( $object_type eq 'RT::System' ) { $obj = $RT::System; - } elsif ( $RT::ACE::OBJECT_TYPES{$object_type} ) { + } elsif ( $object_type->DOES('RT::Record::Role::Rights') ) { $obj = $object_type->new( $session{'CurrentUser'} ); $obj->Load($object_id); unless ( $obj->id ) { @@ -2627,38 +2957,69 @@ sub ProcessTicketReminders { if ( $args->{'update-reminders'} ) { while ( my $reminder = $reminder_collection->Next ) { - my $resolve_status = $reminder->QueueObj->Lifecycle->ReminderStatusOnResolve; - if ( $reminder->Status ne $resolve_status && $args->{ 'Complete-Reminder-' . $reminder->id } ) { - my ($status, $msg) = $Ticket->Reminders->Resolve($reminder); - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); - + my $resolve_status = $reminder->LifecycleObj->ReminderStatusOnResolve; + my ( $status, $msg, $old_subject, @subresults ); + if ( $reminder->Status ne $resolve_status + && $args->{ 'Complete-Reminder-' . $reminder->id } ) + { + ( $status, $msg ) = $Ticket->Reminders->Resolve($reminder); + push @subresults, $msg; } - elsif ( $reminder->Status eq $resolve_status && !$args->{ 'Complete-Reminder-' . $reminder->id } ) { - my ($status, $msg) = $Ticket->Reminders->Open($reminder); - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); + elsif ( $reminder->Status eq $resolve_status + && !$args->{ 'Complete-Reminder-' . $reminder->id } ) + { + ( $status, $msg ) = $Ticket->Reminders->Open($reminder); + push @subresults, $msg; } - if ( exists( $args->{ 'Reminder-Subject-' . $reminder->id } ) && ( $reminder->Subject ne $args->{ 'Reminder-Subject-' . $reminder->id } )) { - my ($status, $msg) = $reminder->SetSubject( $args->{ 'Reminder-Subject-' . $reminder->id } ) ; - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); + if ( + exists( $args->{ 'Reminder-Subject-' . $reminder->id } ) + && ( $reminder->Subject ne + $args->{ 'Reminder-Subject-' . $reminder->id } ) + ) + { + $old_subject = $reminder->Subject; + ( $status, $msg ) = + $reminder->SetSubject( + $args->{ 'Reminder-Subject-' . $reminder->id } ); + push @subresults, $msg; } - if ( exists( $args->{ 'Reminder-Owner-' . $reminder->id } ) && ( $reminder->Owner != $args->{ 'Reminder-Owner-' . $reminder->id } )) { - my ($status, $msg) = $reminder->SetOwner( $args->{ 'Reminder-Owner-' . $reminder->id } , "Force" ) ; - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); + if ( + exists( $args->{ 'Reminder-Owner-' . $reminder->id } ) + && ( $reminder->Owner != + $args->{ 'Reminder-Owner-' . $reminder->id } ) + ) + { + ( $status, $msg ) = + $reminder->SetOwner( + $args->{ 'Reminder-Owner-' . $reminder->id }, "Force" ); + push @subresults, $msg; } - if ( exists( $args->{ 'Reminder-Due-' . $reminder->id } ) && $args->{ 'Reminder-Due-' . $reminder->id } ne '' ) { + if ( exists( $args->{ 'Reminder-Due-' . $reminder->id } ) + && $args->{ 'Reminder-Due-' . $reminder->id } ne '' ) + { my $DateObj = RT::Date->new( $session{'CurrentUser'} ); + my $due = $args->{ 'Reminder-Due-' . $reminder->id }; + $DateObj->Set( Format => 'unknown', - Value => $args->{ 'Reminder-Due-' . $reminder->id } + Value => $due, ); - if ( defined $DateObj->Unix && $DateObj->Unix != $reminder->DueObj->Unix ) { - my ($status, $msg) = $reminder->SetDue( $DateObj->ISO ); - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); + if ( $DateObj->Unix != $reminder->DueObj->Unix ) { + ( $status, $msg ) = $reminder->SetDue( $DateObj->ISO ); } + else { + $msg = loc( "invalid due date: [_1]", $due ); + } + + push @subresults, $msg; } + + push @results, map { + loc( "Reminder '[_1]': [_2]", $old_subject || $reminder->Subject, $_ ) + } @subresults; } } @@ -2668,13 +3029,14 @@ sub ProcessTicketReminders { Format => 'unknown', Value => $args->{'NewReminder-Due'} ); - my ( $add_id, $msg ) = $Ticket->Reminders->Add( + my ( $status, $msg ) = $Ticket->Reminders->Add( Subject => $args->{'NewReminder-Subject'}, Owner => $args->{'NewReminder-Owner'}, Due => $due_obj->ISO ); - if ( $add_id ) { - push @results, loc("Reminder '[_1]' added", $args->{'NewReminder-Subject'}); + if ( $status ) { + push @results, + loc( "Reminder '[_1]': [_2]", $args->{'NewReminder-Subject'}, loc("Created") ) } else { push @results, $msg; @@ -2683,41 +3045,13 @@ sub ProcessTicketReminders { return @results; } -sub ProcessTicketCustomFieldUpdates { - my %args = @_; - $args{'Object'} = delete $args{'TicketObj'}; - my $ARGSRef = { %{ $args{'ARGSRef'} } }; - - # 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+-.*)/ ) { - $ARGSRef->{"Object-RT::Ticket-$1"} = delete $ARGSRef->{$arg}; - } elsif ( $arg =~ /^CustomField-(\d+-.*)/ ) { - $ARGSRef->{"Object-RT::Ticket--$1"} = delete $ARGSRef->{$arg}; - } elsif ( $arg =~ /^Object-RT::Transaction-(\d*)-CustomField/ ) { - delete $ARGSRef->{$arg}; # don't try to update transaction fields - } - } - - return ProcessObjectCustomFieldUpdates( %args, ARGSRef => $ARGSRef ); -} - sub ProcessObjectCustomFieldUpdates { 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---CustomField-- - 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}; - } + my %custom_fields_to_mod = _ParseObjectCustomFieldArgs($ARGSRef); # For each of those objects foreach my $class ( keys %custom_fields_to_mod ) { @@ -2740,12 +3074,34 @@ sub ProcessObjectCustomFieldUpdates { $RT::Logger->warning("Couldn't load custom field #$cf"); next; } + my @groupings = sort keys %{ $custom_fields_to_mod{$class}{$id}{$cf} }; + if (@groupings > 1) { + # Check for consistency, in case of JS fail + for my $key (qw/AddValue Value Values DeleteValues DeleteValueIds/) { + my $base = $custom_fields_to_mod{$class}{$id}{$cf}{$groupings[0]}{$key}; + $base = [ $base ] unless ref $base; + for my $grouping (@groupings[1..$#groupings]) { + my $other = $custom_fields_to_mod{$class}{$id}{$cf}{$grouping}{$key}; + $other = [ $other ] unless ref $other; + warn "CF $cf submitted with multiple differing values" + if grep {$_} List::MoreUtils::pairwise { + no warnings qw(uninitialized); + $a ne $b + } @{$base}, @{$other}; + } + } + # We'll just be picking the 1st grouping in the hash, alphabetically + } push @results, _ProcessObjectCustomFieldUpdates( - Prefix => "Object-$class-$id-CustomField-$cf-", - Object => $Object, - CustomField => $CustomFieldObj, - ARGS => $custom_fields_to_mod{$class}{$id}{$cf}, + Prefix => GetCustomFieldInputNamePrefix( + Object => $Object, + CustomField => $CustomFieldObj, + Grouping => $groupings[0], + ), + Object => $Object, + CustomField => $CustomFieldObj, + ARGS => $custom_fields_to_mod{$class}{$id}{$cf}{ $groupings[0] }, ); } } @@ -2753,6 +3109,26 @@ sub ProcessObjectCustomFieldUpdates { return @results; } +sub _ParseObjectCustomFieldArgs { + my $ARGSRef = shift || {}; + my %custom_fields_to_mod; + + foreach my $arg ( keys %$ARGSRef ) { + + # format: Object---CustomField[:]-- + # you can use GetCustomFieldInputName to generate the complement input name + next unless $arg =~ /^Object-([\w:]+)-(\d*)-CustomField(?::(\w+))?-(\d+)-(.*)$/; + + next if $1 eq 'RT::Transaction';# don't try to update transaction fields + + # For each of those objects, find out what custom fields we want to work with. + # Class ID CF grouping command + $custom_fields_to_mod{$1}{ $2 || 0 }{$4}{$3 || ''}{$5} = $ARGSRef->{$arg}; + } + + return wantarray ? %custom_fields_to_mod : \%custom_fields_to_mod; +} + sub _ProcessObjectCustomFieldUpdates { my %args = @_; my $cf = $args{'CustomField'}; @@ -2762,7 +3138,7 @@ sub _ProcessObjectCustomFieldUpdates { # 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'} ) + && ($args{'ARGS'}->{'Values-Magic'}) ) { delete $args{'ARGS'}->{'Values'}; } @@ -2771,14 +3147,14 @@ sub _ProcessObjectCustomFieldUpdates { foreach my $arg ( keys %{ $args{'ARGS'} } ) { # skip category argument - next if $arg eq 'Category'; + next if $arg =~ /-Category$/; # and TimeUnits next if $arg eq 'Value-TimeUnits'; # since http won't pass in a form element with a null value, we need # to fake it - if ( $arg eq 'Values-Magic' ) { + if ( $arg =~ /-Magic$/ ) { # We don't care about the magic, if there's really a values element; next if defined $args{'ARGS'}->{'Value'} && length $args{'ARGS'}->{'Value'}; @@ -2791,22 +3167,14 @@ sub _ProcessObjectCustomFieldUpdates { $args{'ARGS'}->{'Values'} = undef; } - my @values = (); - if ( ref $args{'ARGS'}->{$arg} eq 'ARRAY' ) { - @values = @{ $args{'ARGS'}->{$arg} }; - } 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 length, map { - s/\r+\n/\n/g; - s/^\s+//; - s/\s+$//; - $_; - } - grep defined, @values; + my @values = _NormalizeObjectCustomFieldValue( + CustomField => $cf, + Param => $args{'Prefix'} . $arg, + Value => $args{'ARGS'}->{$arg} + ); + + # "Empty" values still don't mean anything for Image and Binary fields + next if $cf_type =~ /^(?:Image|Binary)$/ and not @values; if ( $arg eq 'AddValue' || $arg eq 'Value' ) { foreach my $value (@values) { @@ -2817,8 +3185,7 @@ sub _ProcessObjectCustomFieldUpdates { push( @results, $msg ); } } elsif ( $arg eq 'Upload' ) { - my $value_hash = _UploadedFile( $args{'Prefix'} . $arg ) or next; - my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( %$value_hash, Field => $cf, ); + my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( %{$values[0]}, Field => $cf, ); push( @results, $msg ); } elsif ( $arg eq 'DeleteValues' ) { foreach my $value (@values) { @@ -2836,7 +3203,7 @@ sub _ProcessObjectCustomFieldUpdates { ); push( @results, $msg ); } - } elsif ( $arg eq 'Values' && !$cf->Repeated ) { + } elsif ( $arg eq 'Values' ) { my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id ); my %values_hash; @@ -2870,29 +3237,6 @@ sub _ProcessObjectCustomFieldUpdates { ); push( @results, $msg ); } - } 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] ) { - shift @values; - next; - } - - $delete_flag ||= 1; - $old_cf->Delete; - } - - # now add/replace extra things, if any - foreach my $value (@values) { - my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( - Field => $cf, - Value => $value - ); - push( @results, $msg ); - } } else { push( @results, @@ -2906,6 +3250,107 @@ sub _ProcessObjectCustomFieldUpdates { return @results; } +sub ProcessObjectCustomFieldUpdatesForCreate { + my %args = ( + ARGSRef => {}, + ContextObject => undef, + @_ + ); + my $context = $args{'ContextObject'}; + my %parsed; + my %custom_fields = _ParseObjectCustomFieldArgs( $args{'ARGSRef'} ); + + for my $class (keys %custom_fields) { + # we're only interested in new objects, so only look at $id == 0 + for my $cfid (keys %{ $custom_fields{$class}{0} || {} }) { + my $cf = RT::CustomField->new( $session{'CurrentUser'} ); + if ($context) { + my $system_cf = RT::CustomField->new( RT->SystemUser ); + $system_cf->LoadById($cfid); + if ($system_cf->ValidateContextObject($context)) { + $cf->SetContextObject($context); + } else { + RT->Logger->error( + sprintf "Invalid context object %s (%d) for CF %d; skipping CF", + ref $context, $context->id, $system_cf->id + ); + next; + } + } + $cf->LoadById($cfid); + + unless ($cf->id) { + RT->Logger->warning("Couldn't load custom field #$cfid"); + next; + } + + my @groupings = sort keys %{ $custom_fields{$class}{0}{$cfid} }; + if (@groupings > 1) { + # Check for consistency, in case of JS fail + for my $key (qw/AddValue Value Values DeleteValues DeleteValueIds/) { + warn "CF $cfid submitted with multiple differing $key" + if grep {($custom_fields{$class}{0}{$cfid}{$_}{$key} || '') + ne ($custom_fields{$class}{0}{$cfid}{$groupings[0]}{$key} || '')} + @groupings; + } + # We'll just be picking the 1st grouping in the hash, alphabetically + } + + my @values; + my $name_prefix = GetCustomFieldInputNamePrefix( + CustomField => $cf, + Grouping => $groupings[0], + ); + while (my ($arg, $value) = each %{ $custom_fields{$class}{0}{$cfid}{$groupings[0]} }) { + # Values-Magic doesn't matter on create; no previous values are being removed + # Category is irrelevant for the actual value + next if $arg =~ /-Magic$/ or $arg =~ /-Category$/; + + push @values, + _NormalizeObjectCustomFieldValue( + CustomField => $cf, + Param => $name_prefix . $arg, + Value => $value, + ); + } + + $parsed{"CustomField-$cfid"} = \@values if @values; + } + } + + return wantarray ? %parsed : \%parsed; +} + +sub _NormalizeObjectCustomFieldValue { + my %args = ( + Param => "", + @_ + ); + my $cf_type = $args{CustomField}->Type; + my @values = (); + + if ( ref $args{'Value'} eq 'ARRAY' ) { + @values = @{ $args{'Value'} }; + } elsif ( $cf_type =~ /text/i ) { # Both Text and Wikitext + @values = ( $args{'Value'} ); + } else { + @values = split /\r*\n/, $args{'Value'} + if defined $args{'Value'}; + } + @values = grep length, map { + s/\r+\n/\n/g; + s/^\s+//; + s/\s+$//; + $_; + } + grep defined, @values; + + if ($args{'Param'} =~ /-Upload$/ and $cf_type =~ /^(Image|Binary)$/) { + @values = _UploadedFile( $args{'Param'} ) || (); + } + + return @values; +} =head2 ProcessTicketWatchers ( TicketObj => $Ticket, ARGSRef => \%ARGS ); @@ -3010,7 +3455,6 @@ sub ProcessTicketDates { # Set date fields my @date_fields = qw( Told - Resolved Starts Started Due @@ -3031,9 +3475,7 @@ sub ProcessTicketDates { ); my $obj = $field . "Obj"; - if ( ( defined $DateObj->Unix ) - and ( $DateObj->Unix != $Ticket->$obj()->Unix() ) ) - { + if ( $DateObj->Unix != $Ticket->$obj()->Unix() ) { my $method = "Set$field"; my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO ); push @results, "$msg"; @@ -3055,19 +3497,24 @@ Returns an array of results messages. sub ProcessTicketLinks { my %args = ( TicketObj => undef, + TicketId => undef, ARGSRef => undef, @_ ); my $Ticket = $args{'TicketObj'}; + my $TicketId = $args{'TicketId'} || $Ticket->Id; my $ARGSRef = $args{'ARGSRef'}; - my (@results) = ProcessRecordLinks( RecordObj => $Ticket, ARGSRef => $ARGSRef ); + my (@results) = ProcessRecordLinks( + %args, RecordObj => $Ticket, RecordId => $TicketId, ARGSRef => $ARGSRef, + ); #Merge if we need to - if ( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ) { - $ARGSRef->{ $Ticket->Id . "-MergeInto" } =~ s/\s+//g; - my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ); + my $input = $TicketId .'-MergeInto'; + if ( $ARGSRef->{ $input } ) { + $ARGSRef->{ $input } =~ s/\s+//g; + my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $input } ); push @results, $msg; } @@ -3078,11 +3525,13 @@ sub ProcessTicketLinks { sub ProcessRecordLinks { my %args = ( RecordObj => undef, + RecordId => undef, ARGSRef => undef, @_ ); my $Record = $args{'RecordObj'}; + my $RecordId = $args{'RecordId'} || $Record->Id; my $ARGSRef = $args{'ARGSRef'}; my (@results); @@ -3109,11 +3558,12 @@ sub ProcessRecordLinks { my @linktypes = qw( DependsOn MemberOf RefersTo ); foreach my $linktype (@linktypes) { - if ( $ARGSRef->{ $Record->Id . "-$linktype" } ) { - $ARGSRef->{ $Record->Id . "-$linktype" } = join( ' ', @{ $ARGSRef->{ $Record->Id . "-$linktype" } } ) - if ref( $ARGSRef->{ $Record->Id . "-$linktype" } ); + my $input = $RecordId .'-'. $linktype; + if ( $ARGSRef->{ $input } ) { + $ARGSRef->{ $input } = join( ' ', @{ $ARGSRef->{ $input } } ) + if ref $ARGSRef->{ $input }; - for my $luri ( split( / /, $ARGSRef->{ $Record->Id . "-$linktype" } ) ) { + for my $luri ( split( / /, $ARGSRef->{ $input } ) ) { next unless $luri; $luri =~ s/\s+$//; # Strip trailing whitespace my ( $val, $msg ) = $Record->AddLink( @@ -3123,11 +3573,12 @@ sub ProcessRecordLinks { push @results, $msg; } } - if ( $ARGSRef->{ "$linktype-" . $Record->Id } ) { - $ARGSRef->{ "$linktype-" . $Record->Id } = join( ' ', @{ $ARGSRef->{ "$linktype-" . $Record->Id } } ) - if ref( $ARGSRef->{ "$linktype-" . $Record->Id } ); + $input = $linktype .'-'. $RecordId; + if ( $ARGSRef->{ $input } ) { + $ARGSRef->{ $input } = join( ' ', @{ $ARGSRef->{ $input } } ) + if ref $ARGSRef->{ $input }; - for my $luri ( split( / /, $ARGSRef->{ "$linktype-" . $Record->Id } ) ) { + for my $luri ( split( / /, $ARGSRef->{ $input } ) ) { next unless $luri; my ( $val, $msg ) = $Record->AddLink( Base => $luri, @@ -3142,6 +3593,41 @@ sub ProcessRecordLinks { return (@results); } +=head2 ProcessLinksForCreate + +Takes a hash with a single key, C, the value of which is a hashref to +C<%ARGS>. + +Converts and returns submitted args in the form of C and +C into their appropriate directional link types. For example, +C becomes C and C becomes +C. The incoming arg values are split on whitespace and +normalized into arrayrefs before being returned. + +Primarily used by object creation pages for transforming incoming form inputs +from F into arguments appropriate for individual record +Create methods. + +Returns a hashref in scalar context and a hash in list context. + +=cut + +sub ProcessLinksForCreate { + my %args = @_; + my %links; + + foreach my $type ( keys %RT::Link::DIRMAP ) { + for ([Base => "new-$type"], [Target => "$type-new"]) { + my ($direction, $key) = @$_; + next unless $args{ARGSRef}->{$key}; + $links{ $RT::Link::DIRMAP{$type}->{$direction} } = [ + grep $_, split ' ', $args{ARGSRef}->{$key} + ]; + } + } + return wantarray ? %links : \%links; +} + =head2 ProcessTransactionSquelching Takes a hashref of the submitted form arguments, C<%ARGS>. @@ -3160,6 +3646,89 @@ sub ProcessTransactionSquelching { return %squelched; } +sub ProcessRecordBulkCustomFields { + my %args = (RecordObj => undef, ARGSRef => {}, @_); + + my $ARGSRef = $args{'ARGSRef'}; + + my %data; + + my @results; + foreach my $key ( keys %$ARGSRef ) { + next unless $key =~ /^Bulk-(Add|Delete)-CustomField-(\d+)-(.*)$/; + my ($op, $cfid, $rest) = ($1, $2, $3); + next if $rest =~ /-Category$/; + + my $res = $data{$cfid} ||= {}; + unless (keys %$res) { + my $cf = RT::CustomField->new( $session{'CurrentUser'} ); + $cf->Load( $cfid ); + next unless $cf->Id; + + $res->{'cf'} = $cf; + } + + if ( $op eq 'Delete' && $rest eq 'AllValues' ) { + $res->{'DeleteAll'} = $ARGSRef->{$key}; + next; + } + + my @values = _NormalizeObjectCustomFieldValue( + CustomField => $res->{'cf'}, + Value => $ARGSRef->{$key}, + Param => $key, + ); + next unless @values; + $res->{$op} = \@values; + } + + while ( my ($cfid, $data) = each %data ) { + my $current_values = $args{'RecordObj'}->CustomFieldValues( $cfid ); + + # just add one value for fields with single value + if ( $data->{'Add'} && $data->{'cf'}->MaxValues == 1 ) { + next if $current_values->HasEntry($data->{Add}[-1]); + + my ( $id, $msg ) = $args{'RecordObj'}->AddCustomFieldValue( + Field => $cfid, + Value => $data->{'Add'}[-1], + ); + push @results, $msg; + next; + } + + if ( $data->{'DeleteAll'} ) { + while ( my $value = $current_values->Next ) { + my ( $id, $msg ) = $args{'RecordObj'}->DeleteCustomFieldValue( + Field => $cfid, + ValueId => $value->id, + ); + push @results, $msg; + } + } + foreach my $value ( @{ $data->{'Delete'} || [] } ) { + my $entry = $current_values->HasEntry($value); + next unless $entry; + + my ( $id, $msg ) = $args{'RecordObj'}->DeleteCustomFieldValue( + Field => $cfid, + ValueId => $entry->id, + ); + push @results, $msg; + } + foreach my $value ( @{ $data->{'Add'} || [] } ) { + next if $current_values->HasEntry($value); + + my ( $id, $msg ) = $args{'RecordObj'}->AddCustomFieldValue( + Field => $cfid, + Value => $value + ); + push @results, $msg; + } + } + return @results; +} + =head2 _UploadedFile ( $arg ); Takes a CGI parameter name; if a file is uploaded under that name, @@ -3220,10 +3789,13 @@ sub ProcessColumnMapValue { } elsif ( UNIVERSAL::isa( $value, 'SCALAR' ) ) { return $$value; } + } else { + if ($args{'Escape'}) { + $value = $m->interp->apply_escapes( $value, 'h' ); + $value =~ s/\n/
    /g if defined $value; + } + return $value; } - - return $m->interp->apply_escapes( $value, 'h' ) if $args{'Escape'}; - return $value; } =head2 GetPrincipalsMap OBJECT, CATEGORIES @@ -3240,10 +3812,10 @@ sub GetPrincipalsMap { if (/System/) { my $system = RT::Groups->new($session{'CurrentUser'}); $system->LimitToSystemInternalGroups(); - $system->OrderBy( FIELD => 'Type', ORDER => 'ASC' ); + $system->OrderBy( FIELD => 'Name', ORDER => 'ASC' ); push @map, [ 'System' => $system, # loc_left_pair - 'Type' => 1, + 'Name' => 1, ]; } elsif (/Groups/) { @@ -3267,21 +3839,33 @@ sub GetPrincipalsMap { elsif (/Roles/) { my $roles = RT::Groups->new($session{'CurrentUser'}); - if ($object->isa('RT::System')) { - $roles->LimitToRolesForSystem(); - } - elsif ($object->isa('RT::Queue')) { - $roles->LimitToRolesForQueue($object->Id); + if ($object->isa("RT::CustomField")) { + # If we're a custom field, show the global roles for our LookupType. + my $class = $object->RecordClassFromLookupType; + if ($class and $class->DOES("RT::Record::Role::Roles")) { + $roles->LimitToRolesForObject(RT->System); + $roles->Limit( + FIELD => "Name", + FUNCTION => 'LOWER(?)', + OPERATOR => "IN", + VALUE => [ map {lc $_} $class->Roles ], + CASESENSITIVE => 1, + ); + } else { + # No roles to show; so show nothing + undef $roles; + } + } else { + $roles->LimitToRolesForObject($object); } - else { - $RT::Logger->warn("Skipping unknown object type ($object) for Role principals"); - next; + + if ($roles) { + $roles->OrderBy( FIELD => 'Name', ORDER => 'ASC' ); + push @map, [ + 'Roles' => $roles, # loc_left_pair + 'Name' => 1 + ]; } - $roles->OrderBy( FIELD => 'Type', ORDER => 'ASC' ); - push @map, [ - 'Roles' => $roles, # loc_left_pair - 'Type' => 1 - ]; } elsif (/Users/) { my $Users = RT->PrivilegedUsers->UserMembersObj(); @@ -3296,23 +3880,18 @@ sub GetPrincipalsMap { ); # Limit to UserEquiv groups - my $groups = $Users->NewAlias('Groups'); - $Users->Join( - ALIAS1 => $groups, - FIELD1 => 'id', - ALIAS2 => $group_members, - FIELD2 => 'GroupId' + my $groups = $Users->Join( + ALIAS1 => $group_members, + FIELD1 => 'GroupId', + TABLE2 => 'Groups', + FIELD2 => 'id', ); - $Users->Limit( ALIAS => $groups, FIELD => 'Domain', VALUE => 'ACLEquivalence' ); - $Users->Limit( ALIAS => $groups, FIELD => 'Type', VALUE => 'UserEquiv' ); + $Users->Limit( ALIAS => $groups, FIELD => 'Domain', VALUE => 'ACLEquivalence', CASESENSITIVE => 0 ); + $Users->Limit( ALIAS => $groups, FIELD => 'Name', VALUE => 'UserEquiv', CASESENSITIVE => 0 ); - - my $display = sub { - $m->scomp('/Elements/ShowUser', User => $_[0], NoEscape => 1) - }; push @map, [ 'Users' => $Users, # loc_left_pair - $display => 0 + 'Format' => 0 ]; } } @@ -3381,16 +3960,17 @@ following: =cut our @SCRUBBER_ALLOWED_TAGS = qw( - A B U P BR I HR BR SMALL EM FONT SPAN STRONG SUB SUP STRIKE H1 H2 H3 H4 H5 + A B U P BR I HR BR SMALL EM FONT SPAN STRONG SUB SUP S DEL STRIKE H1 H2 H3 H4 H5 H6 DIV UL OL LI DL DT DD PRE BLOCKQUOTE BDO ); our %SCRUBBER_ALLOWED_ATTRIBUTES = ( # Match http, https, ftp, mailto and relative urls # XXX: we also scrub format strings with this module then allow simple config options - href => qr{^(?:https?:|ftp:|mailto:|/|__Web(?:Path|BaseURL|URL)__)}i, + href => qr{^(?:https?:|ftp:|mailto:|/|__Web(?:Path|HomePath|BaseURL|URL)__)}i, face => 1, size => 1, + color => 1, target => 1, style => qr{ ^(?:\s* @@ -3404,6 +3984,12 @@ our %SCRUBBER_ALLOWED_ATTRIBUTES = ( font-family: \s* [\w\s"',.\-]+ | font-weight: \s* [\w\-]+ | + border-style: \s* \w+ | + border-color: \s* [#\w]+ | + border-width: \s* [\s\w]+ | + padding: \s* [\s\w]+ | + margin: \s* [\s\w]+ | + # MS Office styles, which are probably fine. If we don't, then any # associated styles in the same attribute get stripped. mso-[\w\-]+?: \s* [\w\s"',.\-]+ @@ -3416,9 +4002,42 @@ our %SCRUBBER_ALLOWED_ATTRIBUTES = ( our %SCRUBBER_RULES = (); +# If we're displaying images, let embedded ones through +if (RT->Config->Get('ShowTransactionImages') or RT->Config->Get('ShowRemoteImages')) { + $SCRUBBER_RULES{'img'} = { + '*' => 0, + alt => 1, + }; + + my @src; + push @src, qr/^cid:/i + if RT->Config->Get('ShowTransactionImages'); + + push @src, $SCRUBBER_ALLOWED_ATTRIBUTES{'href'} + if RT->Config->Get('ShowRemoteImages'); + + $SCRUBBER_RULES{'img'}->{'src'} = join "|", @src; +} + sub _NewScrubber { require HTML::Scrubber; my $scrubber = HTML::Scrubber->new(); + + if (HTML::Gumbo->require) { + no warnings 'redefine'; + my $orig = \&HTML::Scrubber::scrub; + *HTML::Scrubber::scrub = sub { + my $self = shift; + + eval { $_[0] = HTML::Gumbo->new->parse( $_[0] ); chomp $_[0] }; + warn "HTML::Gumbo pre-parse failed: $@" if $@; + return $orig->($self, @_); + }; + push @SCRUBBER_ALLOWED_TAGS, qw/TABLE THEAD TBODY TFOOT TR TD TH/; + $SCRUBBER_ALLOWED_ATTRIBUTES{$_} = 1 for + qw/colspan rowspan align valign cellspacing cellpadding border width height/; + } + $scrubber->default( 0, { @@ -3447,6 +4066,21 @@ sub JSON { RT::Interface::Web::EncodeJSON(@_); } +sub CSSClass { + my $value = shift; + return '' unless defined $value; + $value =~ s/[^A-Za-z0-9_-]/_/g; + return $value; +} + +sub GetCustomFieldInputName { + RT::Interface::Web::GetCustomFieldInputName(@_); +} + +sub GetCustomFieldInputNamePrefix { + RT::Interface::Web::GetCustomFieldInputNamePrefix(@_); +} + package RT::Interface::Web; RT::Base->_ImportOverlays(); diff --git a/rt/lib/RT/Interface/Web.pm.orig b/rt/lib/RT/Interface/Web.pm.orig deleted file mode 100644 index 59d315431..000000000 --- a/rt/lib/RT/Interface/Web.pm.orig +++ /dev/null @@ -1,3454 +0,0 @@ -# BEGIN BPS TAGGED BLOCK {{{ -# -# COPYRIGHT: -# -# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC -# -# -# (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 }}} - -## Portions Copyright 2000 Tobias Brox - -## This is a library of static subs to be used by the Mason web -## interface to RT - -=head1 NAME - -RT::Interface::Web - - -=cut - -use strict; -use warnings; - -package RT::Interface::Web; - -use RT::SavedSearches; -use URI qw(); -use RT::Interface::Web::Menu; -use RT::Interface::Web::Session; -use Digest::MD5 (); -use Encode qw(); -use List::MoreUtils qw(); -use JSON qw(); - -=head2 SquishedCSS $style - -=cut - -my %SQUISHED_CSS; -sub SquishedCSS { - my $style = shift or die "need name"; - return $SQUISHED_CSS{$style} if $SQUISHED_CSS{$style}; - require RT::Squish::CSS; - my $css = RT::Squish::CSS->new( Style => $style ); - $SQUISHED_CSS{ $css->Style } = $css; - return $css; -} - -=head2 SquishedJS - -=cut - -my $SQUISHED_JS; -sub SquishedJS { - return $SQUISHED_JS if $SQUISHED_JS; - - require RT::Squish::JS; - my $js = RT::Squish::JS->new(); - $SQUISHED_JS = $js; - return $js; -} - -=head2 ClearSquished - -Removes the cached CSS and JS entries, forcing them to be regenerated -on next use. - -=cut - -sub ClearSquished { - undef $SQUISHED_JS; - %SQUISHED_CSS = (); -} - -=head2 EscapeUTF8 SCALARREF - -does a css-busting but minimalist escaping of whatever html you're passing in. - -=cut - -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; -} - - - -=head2 EscapeURI SCALARREF - -Escapes URI component according to RFC2396 - -=cut - -sub EscapeURI { - my $ref = shift; - return unless defined $$ref; - - use bytes; - $$ref =~ s/([^a-zA-Z0-9_.!~*'()-])/uc sprintf("%%%02X", ord($1))/eg; -} - -=head2 EncodeJSON SCALAR - -Encodes the SCALAR to JSON and returns a JSON string. SCALAR may be a simple -value or a reference. - -=cut - -sub EncodeJSON { - JSON::to_json(shift, { utf8 => 1, allow_nonref => 1 }); -} - -sub _encode_surrogates { - my $uni = $_[0] - 0x10000; - return ($uni / 0x400 + 0xD800, $uni % 0x400 + 0xDC00); -} - -sub EscapeJS { - my $ref = shift; - return unless defined $$ref; - - $$ref = "'" . join('', - map { - chr($_) =~ /[a-zA-Z0-9]/ ? chr($_) : - $_ <= 255 ? sprintf("\\x%02X", $_) : - $_ <= 65535 ? sprintf("\\u%04X", $_) : - sprintf("\\u%X\\u%X", _encode_surrogates($_)) - } unpack('U*', $$ref)) - . "'"; -} - -=head2 WebCanonicalizeInfo(); - -Different web servers set different environmental varibles. This -function must return something suitable for REMOTE_USER. By default, -just downcase $ENV{'REMOTE_USER'} - -=cut - -sub WebCanonicalizeInfo { - return $ENV{'REMOTE_USER'} ? lc $ENV{'REMOTE_USER'} : $ENV{'REMOTE_USER'}; -} - - - -=head2 WebExternalAutoInfo($user); - -Returns a hash of user attributes, used when WebExternalAuto is set. - -=cut - -sub WebExternalAutoInfo { - my $user = shift; - - my %user_info; - - # default to making Privileged users, even if they specify - # some other default Attributes - if ( !$RT::AutoCreate - || ( ref($RT::AutoCreate) && not exists $RT::AutoCreate->{Privileged} ) ) - { - $user_info{'Privileged'} = 1; - } - - if ( $^O !~ /^(?:riscos|MacOS|MSWin32|dos|os2)$/ ) { - - # Populate fields with information from Unix /etc/passwd - - 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' ) { - - # Populate fields with information from NT domain controller - } - - # and return the wad of stuff - return {%user_info}; -} - - -sub HandleRequest { - my $ARGS = shift; - - if (RT->Config->Get('DevelMode')) { - require Module::Refresh; - Module::Refresh->refresh; - } - - $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 and $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') ); - - ValidateWebConfig(); - - DecodeARGS($ARGS); - local $HTML::Mason::Commands::DECODED_ARGS = $ARGS; - PreprocessTimeUpdates($ARGS); - - InitializeMenu(); - MaybeShowInstallModePage(); - - $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS ); - SendSessionCookie(); - - if ( _UserLoggedIn() ) { - # make user info up to date - $HTML::Mason::Commands::session{'CurrentUser'} - ->Load( $HTML::Mason::Commands::session{'CurrentUser'}->id ); - undef $HTML::Mason::Commands::session{'CurrentUser'}->{'LangHandle'}; - } - else { - $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new(); - } - - # Process session-related callbacks before any auth attempts - $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Session', CallbackPage => '/autohandler' ); - - MaybeRejectPrivateComponentRequest(); - - 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' ); - - if ( $ARGS->{'NotMobile'} ) { - $HTML::Mason::Commands::session{'NotMobile'} = 1; - } - - unless ( _UserLoggedIn() ) { - _ForceLogout(); - - # Authenticate if the user is trying to login via user/pass query args - my ($authed, $msg) = AttemptPasswordAuthentication($ARGS); - - unless ($authed) { - my $m = $HTML::Mason::Commands::m; - - # REST urls get a special 401 response - if ($m->request_comp->path =~ m{^/REST/\d+\.\d+/}) { - $HTML::Mason::Commands::r->content_type("text/plain"); - $m->error_format("text"); - $m->out("RT/$RT::VERSION 401 Credentials required\n"); - $m->out("\n$msg\n") if $msg; - $m->abort; - } - # Specially handle /index.html and /m/index.html so that we get a nicer URL - elsif ( $m->request_comp->path =~ m{^(/m)?/index\.html$} ) { - my $mobile = $1 ? 1 : 0; - my $next = SetNextPage($ARGS); - $m->comp('/NoAuth/Login.html', - next => $next, - actions => [$msg], - mobile => $mobile); - $m->abort; - } - else { - TangentForLogin($ARGS, results => ($msg ? LoginError($msg) : undef)); - } - } - } - - MaybeShowInterstitialCSRFPage($ARGS); - - # 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(RequestData => { - Path => $HTML::Mason::Commands::m->request_path, - }); - - # Process per-page final cleanup callbacks - $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Final', CallbackPage => '/autohandler' ); - - $HTML::Mason::Commands::m->comp( '/Elements/Footer', %$ARGS ) - unless $HTML::Mason::Commands::r->content_type - =~ qr<^(text|application)/(x-)?(css|javascript)>; -} - -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 LoginError ERROR - -Pushes a login error into the Actions session store and returns the hash key. - -=cut - -sub LoginError { - my $new = shift; - my $key = Digest::MD5::md5_hex( rand(1024) ); - push @{ $HTML::Mason::Commands::session{"Actions"}->{$key} ||= [] }, $new; - $HTML::Mason::Commands::session{'i'}++; - return $key; -} - -=head2 SetNextPage ARGSRef [PATH] - -Intuits and stashes the next page in the sesssion hash. If PATH is -specified, uses that instead of the value of L. Returns -the hash value. - -=cut - -sub SetNextPage { - my $ARGS = shift; - my $next = $_[0] ? $_[0] : IntuitNextPage(); - my $hash = Digest::MD5::md5_hex($next . $$ . rand(1024)); - my $page = { url => $next }; - - # If an explicit URL was passed and we didn't IntuitNextPage, then - # IsPossibleCSRF below is almost certainly unrelated to the actual - # destination. Currently explicit next pages aren't used in RT, but the - # API is available. - if (not $_[0] and RT->Config->Get("RestrictReferrer")) { - # This isn't really CSRF, but the CSRF heuristics are useful for catching - # requests which may have unintended side-effects. - my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS); - if ($is_csrf) { - RT->Logger->notice( - "Marking original destination as having side-effects before redirecting for login.\n" - ."Request: $next\n" - ."Reason: " . HTML::Mason::Commands::loc($msg, @loc) - ); - $page->{'HasSideEffects'} = [$msg, @loc]; - } - } - - $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $page; - $HTML::Mason::Commands::session{'i'}++; - return $hash; -} - -=head2 FetchNextPage HASHKEY - -Returns the stashed next page hashref for the given hash. - -=cut - -sub FetchNextPage { - my $hash = shift || ""; - return $HTML::Mason::Commands::session{'NextPage'}->{$hash}; -} - -=head2 RemoveNextPage HASHKEY - -Removes the stashed next page for the given hash and returns it. - -=cut - -sub RemoveNextPage { - my $hash = shift || ""; - return delete $HTML::Mason::Commands::session{'NextPage'}->{$hash}; -} - -=head2 TangentForLogin ARGSRef [HASH] - -Redirects to C, setting the value of L as -the next page. Takes a hashref of request %ARGS as the first parameter. -Optionally takes all other parameters as a hash which is dumped into query -params. - -=cut - -sub TangentForLogin { - my $ARGS = shift; - my $hash = SetNextPage($ARGS); - my %query = (@_, next => $hash); - - $query{mobile} = 1 - if $HTML::Mason::Commands::m->request_comp->path =~ m{^/m(/|$)}; - - my $login = RT->Config->Get('WebURL') . 'NoAuth/Login.html?'; - $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query); - Redirect($login); -} - -=head2 TangentForLoginWithError ERROR - -Localizes the passed error message, stashes it with L and then -calls L with the appropriate results key. - -=cut - -sub TangentForLoginWithError { - my $ARGS = shift; - my $key = LoginError(HTML::Mason::Commands::loc(@_)); - TangentForLogin( $ARGS, results => $key ); -} - -=head2 IntuitNextPage - -Attempt to figure out the path to which we should return the user after a -tangent. The current request URL is used, or failing that, the C -configuration variable. - -=cut - -sub IntuitNextPage { - my $req_uri; - - # This includes any query parameters. Redirect will take care of making - # it an absolute URL. - if ($ENV{'REQUEST_URI'}) { - $req_uri = $ENV{'REQUEST_URI'}; - - # collapse multiple leading slashes so the first part doesn't look like - # a hostname of a schema-less URI - $req_uri =~ s{^/+}{/}; - } - - my $next = defined $req_uri ? $req_uri : RT->Config->Get('WebURL'); - - # sanitize $next - my $uri = URI->new($next); - - # You get undef scheme with a relative uri like "/Search/Build.html" - unless (!defined($uri->scheme) || $uri->scheme eq 'http' || $uri->scheme eq 'https') { - $next = RT->Config->Get('WebURL'); - } - - # Make sure we're logging in to the same domain - # You can get an undef authority with a relative uri like "index.html" - my $uri_base_url = URI->new(RT->Config->Get('WebBaseURL')); - unless (!defined($uri->authority) || $uri->authority eq $uri_base_url->authority) { - $next = RT->Config->Get('WebURL'); - } - - return $next; -} - -=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 !~ m{^(/+)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'); - - # Don't show the login page to logged in users - Redirect(RT->Config->Get('WebURL')) - if $m->base_comp->path eq '/NoAuth/Login.html' and _UserLoggedIn(); - - # If it's a noauth file, don't ask for auth. - $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS ); - $m->abort; -} - -=head2 MaybeRejectPrivateComponentRequest - -This function will reject calls to private components, like those under -C. If the requested path is a private component then we will -abort with a C<403> error. - -=cut - -sub MaybeRejectPrivateComponentRequest { - my $m = $HTML::Mason::Commands::m; - my $path = $m->request_comp->path; - - # We do not check for dhandler here, because requesting our dhandlers - # directly is okay. Mason will invoke the dhandler with a dhandler_arg of - # 'dhandler'. - - if ($path =~ m{ - / # leading slash - ( Elements | - _elements | # mobile UI - Callbacks | - Widgets | - autohandler | # requesting this directly is suspicious - l (_unsafe)? ) # loc component - ( $ | / ) # trailing slash or end of path - }xi - && $path !~ m{ /RTx/Statistics/\w+/Elements/Chart }xi - ) - { - warn "rejecting private component $path\n"; - $m->abort(403); - } - - return; -} - -sub InitializeMenu { - $HTML::Mason::Commands::m->notes('menu', RT::Interface::Web::Menu->new()); - $HTML::Mason::Commands::m->notes('page-menu', RT::Interface::Web::Menu->new()); - $HTML::Mason::Commands::m->notes('page-widgets', RT::Interface::Web::Menu->new()); - -} - - -=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; - - # Ensure that the cookie that we send is up-to-date, in case the - # session-id has been modified in any way - SendSessionCookie(); - - # precache all system level rights for the current user - $HTML::Mason::Commands::session{CurrentUser}->PrincipalObj->HasRights( Object => RT->System ); - - # 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 =~ m{^(/+)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; - } - - my $next = RemoveNextPage($ARGS->{'next'}); - $next = $next->{'url'} if ref $next; - 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'}; - - if (RT->Config->Get('WebFallbackToInternalAuth')) { - TangentForLoginWithError($ARGS, 'Cannot create user: [_1]', $msg); - } else { - $m->abort(); - } - } - } - - if ( _UserLoggedIn() ) { - $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' ); - # It is possible that we did a redirect to the login page, - # if the external auth allows lack of auth through with no - # REMOTE_USER set, instead of forcing a "permission - # denied" message. Honor the $next. - Redirect($next) if $next; - # Unlike AttemptPasswordAuthentication below, we do not - # force a redirect to / if $next is not set -- otherwise, - # straight-up external auth would always redirect to / - # when you first hit it. - } else { - delete $HTML::Mason::Commands::session{'CurrentUser'}; - $user = $orig_user; - - unless ( RT->Config->Get('WebFallbackToInternalAuth') ) { - TangentForLoginWithError($ARGS, 'You are not an authorized user'); - } - } - } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) { - unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) { - # XXX unreachable due to prior defaulting in HandleRequest (check c34d108) - TangentForLoginWithError($ARGS, 'You are not an authorized user'); - } - } 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; - return unless defined $ARGS->{user} && defined $ARGS->{pass}; - - 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->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' ); - return (0, HTML::Mason::Commands::loc('Your username or password is incorrect')); - } - else { - $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}"); - - # It's important to nab the next page from the session before we blow - # the session away - my $next = RemoveNextPage($ARGS->{'next'}); - $next = $next->{'url'} if ref $next; - - InstantiateNewSession(); - $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj; - - $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' ); - - # Really the only time we don't want to redirect here is if we were - # passed user and pass as query params in the URL. - if ($next) { - Redirect($next); - } - elsif ($ARGS->{'next'}) { - # Invalid hash, but still wants to go somewhere, take them to / - Redirect(RT->Config->Get('WebURL')); - } - - return (1, HTML::Mason::Commands::loc('Logged in')); - } -} - -=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 ) { - InstantiateNewSession(); - } - 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; - SendSessionCookie(); -} - -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 ), - -httponly => ( RT->Config->Get('WebHttpOnlyCookies') ? 1 : 0 ), - ); - - $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string; -} - -=head2 Redirect URL - -This routine ells the current user's browser to redirect to URL. -Additionally, it unties the user's currently active session, helping to avoid -A bug in Apache::Session 1.81 and earlier which clobbers sessions if we try to use -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( _NormalizeHost(RT->Config->Get('WebURL')) ); - - # Make relative URIs absolute from the server host and scheme - $uri->scheme($server_uri->scheme) if not defined $uri->scheme; - if (not defined $uri->host) { - $uri->host($server_uri->host); - $uri->port($server_uri->port); - } - - # 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) - # don't do this if $RT::CanonicalizeRedirectURLs 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'); - } - - # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST - $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'}); - $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 CacheControlExpiresHeaders - -set both Cache-Control and Expires http headers - -=cut - -sub CacheControlExpiresHeaders { - my %args = @_; - - my $Visibility = 'private'; - if ( ! defined $args{Time} ) { - $args{Time} = 0; - } elsif ( $args{Time} eq 'no-cache' ) { - $args{Time} = 0; - } elsif ( $args{Time} eq 'forever' ) { - $args{Time} = 30 * 24 * 60 * 60; - $Visibility = 'public'; - } - - my $CacheControl = $args{Time} - ? sprintf "max-age=%d, %s", $args{Time}, $Visibility - : 'no-cache' - ; - $HTML::Mason::Commands::r->headers_out->{'Cache-Control'} = $CacheControl; - - my $expires = RT::Date->new(RT->SystemUser); - $expires->SetToNow; - $expires->AddSeconds( $args{Time} ) if $args{Time}; - - $HTML::Mason::Commands::r->headers_out->{'Expires'} = $expires->RFC2616; -} - -=head2 StaticFileHeaders - -Send the browser a few headers to try to get it to (somewhat agressively) -cache RT's static Javascript and CSS files. - -This routine could really use _accurate_ heuristics. (XXX TODO) - -=cut - -sub StaticFileHeaders { - my $date = RT::Date->new(RT->SystemUser); - - # remove any cookie headers -- if it is cached publicly, it - # shouldn't include anyone's cookie! - delete $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'}; - - # Expire things in a month. - CacheControlExpiresHeaders( Time => 'forever' ); - - # 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 - # $date->Set( Value => $^T ); - # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616; -} - -=head2 ComponentPathIsSafe PATH - -Takes C and returns a boolean indicating that the user-specified partial -component path is safe. - -Currently "safe" means that the path does not start with a dot (C<.>), does -not contain a slash-dot C, and does not contain any nulls. - -=cut - -sub ComponentPathIsSafe { - my $self = shift; - my $path = shift; - return($path !~ m{(?:^|/)\.} and $path !~ m{\0}); -} - -=head2 PathIsSafe - -Takes a C<< Path => path >> and returns a boolean indicating that -the path is safely within RT's control or not. The path I be -relative. - -This function does not consult the filesystem at all; it is merely -a logical sanity checking of the path. This explicitly does not handle -symlinks; if you have symlinks in RT's webroot pointing outside of it, -then we assume you know what you are doing. - -=cut - -sub PathIsSafe { - my $self = shift; - my %args = @_; - my $path = $args{Path}; - - # Get File::Spec to clean up extra /s, ./, etc - my $cleaned_up = File::Spec->canonpath($path); - - if (!defined($cleaned_up)) { - $RT::Logger->info("Rejecting path that canonpath doesn't understand: $path"); - return 0; - } - - # Forbid too many ..s. We can't just sum then check because - # "../foo/bar/baz" should be illegal even though it has more - # downdirs than updirs. So as soon as we get a negative score - # (which means "breaking out" of the top level) we reject the path. - - my @components = split '/', $cleaned_up; - my $score = 0; - for my $component (@components) { - if ($component eq '..') { - $score--; - if ($score < 0) { - $RT::Logger->info("Rejecting unsafe path: $path"); - return 0; - } - } - elsif ($component eq '.' || $component eq '') { - # these two have no effect on $score - } - else { - $score++; - } - } - - return 1; -} - -=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}; - my $relfile = $args{RelativeFile}; - - if (defined($relfile) && !$self->PathIsSafe(Path => $relfile)) { - $HTML::Mason::Commands::r->status(400); - $HTML::Mason::Commands::m->abort; - } - - $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 MobileClient { - my $self = shift; - - -if (($ENV{'HTTP_USER_AGENT'} || '') =~ /(?:hiptop|Blazer|Novarra|Vagabond|SonyEricsson|Symbian|NetFront|UP.Browser|UP.Link|Windows CE|MIDP|J2ME|DoCoMo|J-PHONE|PalmOS|PalmSource|iPhone|iPod|AvantGo|Nokia|Android|WebOS|S60|Mobile)/io && !$HTML::Mason::Commands::session{'NotMobile'}) { - return 1; -} else { - return undef; -} - -} - - -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!
    !!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; we don't use EscapeUTF8 here - # because we want to precisely match the escapting that FCKEditor - # uses. - $sig =~ s/&/&/g; - $sig =~ s//>/g; - $sig =~ s/"/"/g; - $sig =~ s/'/'/g; - return '' if $html and $content =~ m{^(?:

    )?(--)?\Q$sig\E(?:

    )?$}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 %args = @_; - - my $log_sql_statements = RT->Config->Get('StatementLog'); - - return unless ($log_sql_statements); - - my @log = $RT::Handle->SQLStatementLog; - $RT::Handle->ClearSQLStatementLog; - - $RT::Handle->AddRequestToHistory({ - %{ $args{RequestData} }, - Queries => \@log, - }); - - 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{ defined $_ ? qq|'$_'| : 'undef'} @bind]} ]" : "" ) - ); - } - -} - -my $_has_validated_web_config = 0; -sub ValidateWebConfig { - my $self = shift; - - # do this once per server instance, not once per request - return if $_has_validated_web_config; - $_has_validated_web_config = 1; - - my $port = $ENV{SERVER_PORT}; - my $host = $ENV{HTTP_X_FORWARDED_HOST} || $ENV{HTTP_X_FORWARDED_SERVER} - || $ENV{HTTP_HOST} || $ENV{SERVER_NAME}; - ($host, $port) = ($1, $2) if $host =~ /^(.*?):(\d+)$/; - - if ( $port != RT->Config->Get('WebPort') and not $ENV{'rt.explicit_port'}) { - $RT::Logger->warn("The requested port ($port) does NOT match the configured WebPort ($RT::WebPort). " - ."Perhaps you should Set(\$WebPort, $port); in RT_SiteConfig.pm, " - ."otherwise your internal links may be broken."); - } - - if ( $host ne RT->Config->Get('WebDomain') ) { - $RT::Logger->warn("The requested host ($host) does NOT match the configured WebDomain ($RT::WebDomain). " - ."Perhaps you should Set(\$WebDomain, '$host'); in RT_SiteConfig.pm, " - ."otherwise your internal links may be broken."); - } - - return; #next warning flooding our logs, doesn't seem applicable to our use - # (SCRIPT_NAME is the full path, WebPath is just the beginning) - #in vanilla RT does something eat the local part of SCRIPT_NAME 1st? - - # Unfortunately, there is no reliable way to get the _path_ that was - # requested at the proxy level; simply disable this warning if we're - # proxied and there's a mismatch. - my $proxied = $ENV{HTTP_X_FORWARDED_HOST} || $ENV{HTTP_X_FORWARDED_SERVER}; - if ($ENV{SCRIPT_NAME} ne RT->Config->Get('WebPath') and not $proxied) { - $RT::Logger->warn("The requested path ($ENV{SCRIPT_NAME}) does NOT match the configured WebPath ($RT::WebPath). " - ."Perhaps you should Set(\$WebPath, '$ENV{SCRIPT_NAME}'); in RT_SiteConfig.pm, " - ."otherwise your internal links may be broken."); - } -} - -sub ComponentRoots { - my $self = shift; - my %args = ( Names => 0, @_ ); - my @roots; - if (defined $HTML::Mason::Commands::m) { - @roots = $HTML::Mason::Commands::m->interp->comp_root_array; - } else { - @roots = ( - [ local => $RT::MasonLocalComponentRoot ], - (map {[ "plugin-".$_->Name => $_->ComponentRoot ]} @{RT->Plugins}), - [ standard => $RT::MasonComponentRoot ] - ); - } - @roots = map { $_->[1] } @roots unless $args{Names}; - return @roots; -} - -our %is_whitelisted_component = ( - # The RSS feed embeds an auth token in the path, but query - # information for the search. Because it's a straight-up read, in - # addition to embedding its own auth, it's fine. - '/NoAuth/rss/dhandler' => 1, - - # While these can be used for denial-of-service against RT - # (construct a very inefficient query and trick lots of users into - # running them against RT) it's incredibly useful to be able to link - # to a search result (or chart) or bookmark a result page. - '/Search/Results.html' => 1, - '/Search/Simple.html' => 1, - '/m/tickets/search' => 1, - '/Search/Chart.html' => 1, - - # This page takes Attachment and Transaction argument to figure - # out what to show, but it's read only and will deny information if you - # don't have ShowOutgoingEmail. - '/Ticket/ShowEmailRecord.html' => 1, -); - -# Components which are blacklisted from automatic, argument-based whitelisting. -# These pages are not idempotent when called with just an id. -our %is_blacklisted_component = ( - # Takes only id and toggles bookmark state - '/Helpers/Toggle/TicketBookmark' => 1, -); - -sub IsCompCSRFWhitelisted { - my $comp = shift; - my $ARGS = shift; - - return 1 if $is_whitelisted_component{$comp}; - - my %args = %{ $ARGS }; - - # If the user specifies a *correct* user and pass then they are - # golden. This acts on the presumption that external forms may - # hardcode a username and password -- if a malicious attacker knew - # both already, CSRF is the least of your problems. - my $AllowLoginCSRF = not RT->Config->Get('RestrictReferrerLogin'); - if ($AllowLoginCSRF and defined($args{user}) and defined($args{pass})) { - my $user_obj = RT::CurrentUser->new(); - $user_obj->Load($args{user}); - return 1 if $user_obj->id && $user_obj->IsPassword($args{pass}); - - delete $args{user}; - delete $args{pass}; - } - - # Some pages aren't idempotent even with safe args like id; blacklist - # them from the automatic whitelisting below. - return 0 if $is_blacklisted_component{$comp}; - - # Eliminate arguments that do not indicate an effectful request. - # For example, "id" is acceptable because that is how RT retrieves a - # record. - delete $args{id}; - - # If they have a results= from MaybeRedirectForResults, that's also fine. - delete $args{results}; - - # The homepage refresh, which uses the Refresh header, doesn't send - # a referer in most browsers; whitelist the one parameter it reloads - # with, HomeRefreshInterval, which is safe - delete $args{HomeRefreshInterval}; - - # The NotMobile flag is fine for any page; it's only used to toggle a flag - # in the session related to which interface you get. - delete $args{NotMobile}; - - # If there are no arguments, then it's likely to be an idempotent - # request, which are not susceptible to CSRF - return 1 if !%args; - - return 0; -} - -sub IsRefererCSRFWhitelisted { - my $referer = _NormalizeHost(shift); - my $base_url = _NormalizeHost(RT->Config->Get('WebBaseURL')); - $base_url = $base_url->host_port; - - my $configs; - for my $config ( $base_url, RT->Config->Get('ReferrerWhitelist') ) { - push @$configs,$config; - - my $host_port = $referer->host_port; - if ($config =~ /\*/) { - # Turn a literal * into a domain component or partial component match. - # Refer to http://tools.ietf.org/html/rfc2818#page-5 - my $regex = join "[a-zA-Z0-9\-]*", - map { quotemeta($_) } - split /\*/, $config; - - return 1 if $host_port =~ /^$regex$/i; - } else { - return 1 if $host_port eq $config; - } - } - - return (0,$referer,$configs); -} - -=head3 _NormalizeHost - -Takes a URI and creates a URI object that's been normalized -to handle common problems such as localhost vs 127.0.0.1 - -=cut - -sub _NormalizeHost { - my $s = shift; - $s = "http://$s" unless $s =~ /^http/i; - my $uri= URI->new($s); - $uri->host('127.0.0.1') if $uri->host eq 'localhost'; - - return $uri; - -} - -sub IsPossibleCSRF { - my $ARGS = shift; - - # If first request on this session is to a REST endpoint, then - # whitelist the REST endpoints -- and explicitly deny non-REST - # endpoints. We do this because using a REST cookie in a browser - # would open the user to CSRF attacks to the REST endpoints. - my $path = $HTML::Mason::Commands::r->path_info; - $HTML::Mason::Commands::session{'REST'} = $path =~ m{^/+REST/\d+\.\d+(/|$)} - unless defined $HTML::Mason::Commands::session{'REST'}; - - if ($HTML::Mason::Commands::session{'REST'}) { - return 0 if $path =~ m{^/+REST/\d+\.\d+(/|$)}; - my $why = < $details ); - } - - return 0 if IsCompCSRFWhitelisted( - $HTML::Mason::Commands::m->request_comp->path, - $ARGS - ); - - # if there is no Referer header then assume the worst - return (1, - "your browser did not supply a Referrer header", # loc - ) if !$ENV{HTTP_REFERER}; - - my ($whitelisted, $browser, $configs) = IsRefererCSRFWhitelisted($ENV{HTTP_REFERER}); - return 0 if $whitelisted; - - if ( @$configs > 1 ) { - return (1, - "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2]) or whitelisted hosts ([_3])", # loc - $browser->host_port, - shift @$configs, - join(', ', @$configs) ); - } - - return (1, - "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2])", # loc - $browser->host_port, - $configs->[0]); -} - -sub ExpandCSRFToken { - my $ARGS = shift; - - my $token = delete $ARGS->{CSRF_Token}; - return unless $token; - - my $data = $HTML::Mason::Commands::session{'CSRF'}{$token}; - return unless $data; - return unless $data->{path} eq $HTML::Mason::Commands::r->path_info; - - my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj; - return unless $user->ValidateAuthString( $data->{auth}, $token ); - - %{$ARGS} = %{$data->{args}}; - $HTML::Mason::Commands::DECODED_ARGS = $ARGS; - - # We explicitly stored file attachments with the request, but not in - # the session yet, as that would itself be an attack. Put them into - # the session now, so they'll be visible. - if ($data->{attach}) { - my $filename = $data->{attach}{filename}; - my $mime = $data->{attach}{mime}; - $HTML::Mason::Commands::session{'Attachments'}{$filename} - = $mime; - } - - return 1; -} - -sub StoreRequestToken { - my $ARGS = shift; - - my $token = Digest::MD5::md5_hex(time . {} . $$ . rand(1024)); - my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj; - my $data = { - auth => $user->GenerateAuthString( $token ), - path => $HTML::Mason::Commands::r->path_info, - args => $ARGS, - }; - if ($ARGS->{Attach}) { - my $attachment = HTML::Mason::Commands::MakeMIMEEntity( AttachmentFieldName => 'Attach' ); - my $file_path = delete $ARGS->{'Attach'}; - $data->{attach} = { - filename => Encode::decode_utf8("$file_path"), - mime => $attachment, - }; - } - - $HTML::Mason::Commands::session{'CSRF'}->{$token} = $data; - $HTML::Mason::Commands::session{'i'}++; - return $token; -} - -sub MaybeShowInterstitialCSRFPage { - my $ARGS = shift; - - return unless RT->Config->Get('RestrictReferrer'); - - # Deal with the form token provided by the interstitial, which lets - # browsers which never set referer headers still use RT, if - # painfully. This blows values into ARGS - return if ExpandCSRFToken($ARGS); - - my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS); - return if !$is_csrf; - - $RT::Logger->notice("Possible CSRF: ".RT::CurrentUser->new->loc($msg, @loc)); - - my $token = StoreRequestToken($ARGS); - $HTML::Mason::Commands::m->comp( - '/Elements/CSRF', - OriginalURL => RT->Config->Get('WebPath') . $HTML::Mason::Commands::r->path_info, - Reason => HTML::Mason::Commands::loc( $msg, @loc ), - Token => $token, - ); - # Calls abort, never gets here -} - -our @POTENTIAL_PAGE_ACTIONS = ( - qr'/Ticket/Create.html' => "create a ticket", # loc - qr'/Ticket/' => "update a ticket", # loc - qr'/Admin/' => "modify RT's configuration", # loc - qr'/Approval/' => "update an approval", # loc - qr'/Articles/' => "update an article", # loc - qr'/Dashboards/' => "modify a dashboard", # loc - qr'/m/ticket/' => "update a ticket", # loc - qr'Prefs' => "modify your preferences", # loc - qr'/Search/' => "modify or access a search", # loc - qr'/SelfService/Create' => "create a ticket", # loc - qr'/SelfService/' => "update a ticket", # loc -); - -sub PotentialPageAction { - my $page = shift; - my @potentials = @POTENTIAL_PAGE_ACTIONS; - while (my ($pattern, $result) = splice @potentials, 0, 2) { - return HTML::Mason::Commands::loc($result) - if $page =~ $pattern; - } - return ""; -} - -package HTML::Mason::Commands; - -use vars qw/$r $m %session/; - -sub Menu { - return $HTML::Mason::Commands::m->notes('menu'); -} - -sub PageMenu { - return $HTML::Mason::Commands::m->notes('page-menu'); -} - -sub PageWidgets { - return $HTML::Mason::Commands::m->notes('page-widgets'); -} - - - -=head2 loc ARRAY - -loc is a nice clean global routine which calls $session{'CurrentUser'}->loc() -with whatever it's called with. If there is no $session{'CurrentUser'}, -it creates a temporary user, so we have something to get a localisation handle -through - -=cut - -sub loc { - - 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]; - } -} - - - -=head2 loc_fuzzy STRING - -loc_fuzzy is for handling localizations of messages that may already -contain interpolated variables, typically returned from libraries -outside RT's control. It takes the message string and extracts the -variable array automatically by matching against the candidate entries -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) ); - } -} - - -# 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 => $why, %args ); - $m->abort; - } else { - $m->comp( "/Elements/Error", Why => $why, %args ); - $m->abort; - } -} - -sub MaybeRedirectForResults { - my %args = ( - Path => $HTML::Mason::Commands::m->request_comp->path, - Arguments => {}, - Anchor => undef, - Actions => undef, - Force => 0, - @_ - ); - my $has_actions = $args{'Actions'} && grep( defined, @{ $args{'Actions'} } ); - return unless $has_actions || $args{'Force'}; - - my %arguments = %{ $args{'Arguments'} }; - - if ( $has_actions ) { - my $key = Digest::MD5::md5_hex( rand(1024) ); - push @{ $session{"Actions"}{ $key } ||= [] }, @{ $args{'Actions'} }; - $session{'i'}++; - $arguments{'results'} = $key; - } - - $args{'Path'} =~ s!^/+!!; - my $url = RT->Config->Get('WebURL') . $args{Path}; - - if ( keys %arguments ) { - $url .= '?'. $m->comp( '/Elements/QueryString', %arguments ); - } - if ( $args{'Anchor'} ) { - $url .= "#". $args{'Anchor'}; - } - return RT::Interface::Web::Redirect($url); -} - -=head2 MaybeRedirectToApproval Path => 'path', Whitelist => REGEX, ARGSRef => HASHREF - -If the ticket specified by C<< $ARGSRef->{id} >> is an approval ticket, -redirect to the approvals display page, preserving any arguments. - -Cs matching C are let through. - -This is a no-op if the C option isn't enabled. - -=cut - -sub MaybeRedirectToApproval { - my %args = ( - Path => $HTML::Mason::Commands::m->request_comp->path, - ARGSRef => {}, - Whitelist => undef, - @_ - ); - - return unless $ENV{REQUEST_METHOD} eq 'GET'; - - my $id = $args{ARGSRef}->{id}; - - if ( $id - and RT->Config->Get('ForceApprovalsView') - and not $args{Path} =~ /$args{Whitelist}/) - { - my $ticket = RT::Ticket->new( $session{'CurrentUser'} ); - $ticket->Load($id); - - if ($ticket and $ticket->id and lc($ticket->Type) eq 'approval') { - MaybeRedirectForResults( - Path => "/Approvals/Display.html", - Force => 1, - Anchor => $args{ARGSRef}->{Anchor}, - Arguments => $args{ARGSRef}, - ); - } - } -} - -=head2 CreateTicket ARGS - -Create a new ticket, using Mason's %ARGS. returns @results. - -=cut - -sub CreateTicket { - my %ARGS = (@_); - - my (@Actions); - - my $Ticket = RT::Ticket->new( $session{'CurrentUser'} ); - - my $Queue = RT::Queue->new( $session{'CurrentUser'} ); - unless ( $Queue->Load( $ARGS{'Queue'} ) ) { - Abort('Queue not found'); - } - - unless ( $Queue->CurrentUserHasRight('CreateTicket') ) { - Abort('You have no permission to create tickets in that queue.'); - } - - my $due; - if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) { - $due = RT::Date->new( $session{'CurrentUser'} ); - $due->Set( Format => 'unknown', Value => $ARGS{'Due'} ); - } - my $starts; - if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) { - $starts = RT::Date->new( $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 => $sigless, - Type => $ARGS{'ContentType'}, - Interface => RT::Interface::Web::MobileClient() ? 'Mobile' : 'Web', - ); - - if ( $ARGS{'Attachments'} ) { - my $rv = $MIMEObj->make_multipart; - $RT::Logger->error("Couldn't make multipart message") - if !$rv || $rv !~ /^(?:DONE|ALREADY)$/; - - foreach ( map $ARGS{Attachments}->{$_}, sort keys %{ $ARGS{'Attachments'} } ) { - unless ($_) { - $RT::Logger->error("Couldn't add empty attachemnt"); - next; - } - $MIMEObj->add_part($_); - } - } - - for my $argument (qw(Encrypt Sign)) { - $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 ); - } - - my %create_args = ( - 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'}, - TimeWorked => $ARGS{'TimeWorked'}, - Subject => $ARGS{'Subject'}, - Status => $ARGS{'Status'}, - Due => $due ? $due->ISO : undef, - Starts => $starts ? $starts->ISO : undef, - MIMEObj => $MIMEObj - ); - - my @txn_squelch; - foreach my $type (qw(Requestor Cc AdminCc)) { - push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$type} ) - if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] }; - } - $create_args{TransSquelchMailTo} = \@txn_squelch - if @txn_squelch; - - if ( $ARGS{'AttachTickets'} ) { - require RT::Action::SendEmail; - RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets, - ref $ARGS{'AttachTickets'} - ? @{ $ARGS{'AttachTickets'} } - : ( $ARGS{'AttachTickets'} ) ); - } - - foreach my $arg ( keys %ARGS ) { - next if $arg =~ /-(?:Magic|Category)$/; - - if ( $arg =~ /^Object-RT::Transaction--CustomField-/ ) { - $create_args{$arg} = $ARGS{$arg}; - } - - # Object-RT::Ticket--CustomField-3-Values - elsif ( $arg =~ /^Object-RT::Ticket--CustomField-(\d+)/ ) { - my $cfid = $1; - - my $cf = RT::CustomField->new( $session{'CurrentUser'} ); - $cf->SetContextObject( $Queue ); - $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); - next; - } - - my $type = $cf->Type; - - 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; - - $create_args{"CustomField-$cfid"} = \@values; - } - } - - # 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} ]; - - } - - my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args); - unless ($id) { - Abort($ErrMsg); - } - - push( @Actions, split( "\n", $ErrMsg ) ); - unless ( $Ticket->CurrentUserHasRight('ShowTicket') ) { - Abort( "No permission to view newly created ticket #" . $Ticket->id . "." ); - } - return ( $Ticket, @Actions ); - -} - - - -=head2 LoadTicket id - -Takes a ticket id as its only variable. if it's handed an array, it takes -the first value. - -Returns an RT::Ticket object as the current user. - -=cut - -sub LoadTicket { - my $id = shift; - - if ( ref($id) eq "ARRAY" ) { - $id = $id->[0]; - } - - unless ($id) { - Abort("No ticket specified"); - } - - my $Ticket = RT::Ticket->new( $session{'CurrentUser'} ); - $Ticket->Load($id); - unless ( $Ticket->id ) { - Abort("Could not load ticket $id"); - } - return $Ticket; -} - - - -=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 - -# change from stock: if txn custom fields are set but there's no content -# or attachment, create a Touch txn instead of doing nothing - -sub ProcessUpdateMessage { - - my %args = ( - ARGSRef => undef, - TicketObj => undef, - SkipSignatureOnly => 1, - @_ - ); - - if ( $args{ARGSRef}->{'UpdateAttachments'} - && !keys %{ $args{ARGSRef}->{'UpdateAttachments'} } ) - { - delete $args{ARGSRef}->{'UpdateAttachments'}; - } - - # 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, - ); - - my %txn_customfields; - - foreach my $key ( keys %{ $args{ARGSRef} } ) { - if ( $key =~ /^(?:Object-RT::Transaction--)?CustomField-(\d+)/ ) { - next if $key =~ /(TimeUnits|Magic)$/; - $txn_customfields{$key} = $args{ARGSRef}->{$key}; - } - } - - # If, after stripping the signature, we have no message, create a - # Touch transaction if necessary - 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'}; - # } - - my $timetaken = $args{ARGSRef}->{'UpdateTimeWorked'}; - if ( $timetaken or grep {length $_} values %txn_customfields ) { - my ( $Transaction, $Description, $Object ) = - $args{TicketObj}->Touch( - CustomFields => \%txn_customfields, - TimeTaken => $timetaken - ); - return $Description; - } - return; - } - - if ( $args{ARGSRef}->{'UpdateSubject'} eq ($args{'TicketObj'}->Subject || '') ) { - $args{ARGSRef}->{'UpdateSubject'} = undef; - } - - my $Message = MakeMIMEEntity( - Subject => $args{ARGSRef}->{'UpdateSubject'}, - Body => $args{ARGSRef}->{'UpdateContent'}, - Type => $args{ARGSRef}->{'UpdateContentType'}, - Interface => RT::Interface::Web::MobileClient() ? 'Mobile' : 'Web', - ); - - $Message->head->replace( 'Message-ID' => Encode::encode_utf8( - 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(); - } - - 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 map $args{ARGSRef}->{UpdateAttachments}{$_}, - sort keys %{ $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 %message_args = ( - Sign => ( $args{ARGSRef}->{'Sign'} ? 1 : 0 ), - Encrypt => ( $args{ARGSRef}->{'Encrypt'} ? 1 : 0 ), - MIMEObj => $Message, - TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'}, - CustomFields => \%txn_customfields, - ); - - _ProcessUpdateMessageRecipients( - MessageArgs => \%message_args, - %args, - ); - - my @results; - if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) { - 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(%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.") ); - } - return @results; -} - -sub _ProcessUpdateMessageRecipients { - my %args = ( - ARGSRef => undef, - TicketObj => undef, - MessageArgs => undef, - @_, - ); - - my $bcc = $args{ARGSRef}->{'UpdateBcc'}; - my $cc = $args{ARGSRef}->{'UpdateCc'}; - - my $message_args = $args{MessageArgs}; - - $message_args->{CcMessageTo} = $cc; - $message_args->{BccMessageTo} = $bcc; - - my @txn_squelch; - foreach my $type (qw(Cc AdminCc)) { - if (grep $_ eq $type || $_ eq ( $type . 's' ), @{ $args{ARGSRef}->{'SkipNotification'} || [] }) { - push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{$type} ); - push @txn_squelch, $args{TicketObj}->$type->MemberEmailAddresses; - push @txn_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses; - } - } - if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) { - push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{Requestor} ); - push @txn_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses; - } - - push @txn_squelch, @{$args{ARGSRef}{SquelchMailTo}} if $args{ARGSRef}{SquelchMailTo}; - $message_args->{SquelchMailTo} = \@txn_squelch - if @txn_squelch; - - 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; - } - } - } -} - -sub ProcessAttachments { - my %args = ( - ARGSRef => {}, - @_ - ); - - my $ARGSRef = $args{ARGSRef} || {}; - # deal with deleting uploaded attachments - foreach my $key ( keys %$ARGSRef ) { - if ( $key =~ m/^DeleteAttach-(.+)$/ ) { - delete $session{'Attachments'}{$1}; - } - $session{'Attachments'} = { %{ $session{'Attachments'} || {} } }; - } - - # store the uploaded attachment in session - if ( defined $ARGSRef->{'Attach'} && length $ARGSRef->{'Attach'} ) - { # attachment? - my $attachment = MakeMIMEEntity( AttachmentFieldName => 'Attach' ); - - my $file_path = Encode::decode_utf8("$ARGSRef->{'Attach'}"); - $session{'Attachments'} = - { %{ $session{'Attachments'} || {} }, $file_path => $attachment, }; - } - - # delete temporary storage entry to make WebUI clean - unless ( keys %{ $session{'Attachments'} } and $ARGSRef->{'UpdateAttach'} ) - { - delete $session{'Attachments'}; - } -} - - -=head2 MakeMIMEEntity PARAMHASH - -Takes a paramhash Subject, Body and AttachmentFieldName. - -Also takes Form, Cc and Type as optional paramhash keys. - - Returns a MIME::Entity. - -=cut - -sub MakeMIMEEntity { - - #TODO document what else this takes. - my %args = ( - Subject => undef, - From => undef, - Cc => undef, - Body => undef, - AttachmentFieldName => undef, - Type => undef, - Interface => 'API', - @_, - ); - my $Message = MIME::Entity->build( - Type => 'multipart/mixed', - "Message-Id" => Encode::encode_utf8( RT::Interface::Email::GenMessageId ), - "X-RT-Interface" => $args{Interface}, - map { $_ => Encode::encode_utf8( $args{ $_} ) } - grep defined $args{$_}, qw(Subject From Cc) - ); - - if ( defined $args{'Body'} && length $args{'Body'} ) { - - # Make the update content have no 'weird' newlines in it - $args{'Body'} =~ s/\r\n/\n/gs; - - $Message->attach( - Type => $args{'Type'} || 'text/plain', - Charset => 'UTF-8', - Data => $args{'Body'}, - ); - } - - if ( $args{'AttachmentFieldName'} ) { - - my $cgi_object = $m->cgi_object; - my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} ); - if ( defined $filehandle && length $filehandle ) { - - my ( @content, $buffer ); - while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) { - push @content, $buffer; - } - - my $uploadinfo = $cgi_object->uploadInfo($filehandle); - - my $filename = "$filehandle"; - $filename =~ s{^.*[\\/]}{}; - - $Message->attach( - Type => $uploadinfo->{'Content-Type'}, - Filename => $filename, - Data => \@content, - ); - if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) { - $Message->head->set( 'Subject' => $filename ); - } - - # Attachment parts really shouldn't get a Message-ID or "interface" - $Message->head->delete('Message-ID'); - $Message->head->delete('X-RT-Interface'); - } - } - - $Message->make_singlepart; - - RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8 - - return ($Message); - -} - - - -=head2 ParseDateToISO - -Takes a date in an arbitrary format. -Returns an ISO date and time in GMT - -=cut - -sub ParseDateToISO { - my $date = shift; - - my $date_obj = RT::Date->new( $session{'CurrentUser'} ); - $date_obj->Set( - Format => 'unknown', - Value => $date - ); - return ( $date_obj->ISO ); -} - - - -sub ProcessACLChanges { - my $ARGSref = shift; - - my @results; - - foreach my $arg ( keys %$ARGSref ) { - next unless ( $arg =~ /^(GrantRight|RevokeRight)-(\d+)-(.+?)-(\d+)$/ ); - - my ( $method, $principal_id, $object_type, $object_id ) = ( $1, $2, $3, $4 ); - - my @rights; - if ( UNIVERSAL::isa( $ARGSref->{$arg}, 'ARRAY' ) ) { - @rights = @{ $ARGSref->{$arg} }; - } else { - @rights = $ARGSref->{$arg}; - } - @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; - } - } 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); -} - - -=head2 ProcessACLs - -ProcessACLs expects values from a series of checkboxes that describe the full -set of rights a principal should have on an object. - -It expects form inputs with names like SetRights-PrincipalId-ObjType-ObjId -instead of with the prefixes Grant/RevokeRight. Each input should be an array -listing the rights the principal should have, and ProcessACLs will modify the -current rights to match. Additionally, the previously unused CheckACL input -listing PrincipalId-ObjType-ObjId is now used to catch cases when all the -rights are removed from a principal and as such no SetRights input is -submitted. - -=cut - -sub ProcessACLs { - my $ARGSref = shift; - my (%state, @results); - - my $CheckACL = $ARGSref->{'CheckACL'}; - my @check = grep { defined } (ref $CheckACL eq 'ARRAY' ? @$CheckACL : $CheckACL); - - # Check if we want to grant rights to a previously rights-less user - for my $type (qw(user group)) { - my $principal = _ParseACLNewPrincipal($ARGSref, $type) - or next; - - unless ($principal->PrincipalId) { - push @results, loc("Couldn't load the specified principal"); - next; - } - - my $principal_id = $principal->PrincipalId; - - # Turn our addprincipal rights spec into a real one - for my $arg (keys %$ARGSref) { - next unless $arg =~ /^SetRights-addprincipal-(.+?-\d+)$/; - - my $tuple = "$principal_id-$1"; - my $key = "SetRights-$tuple"; - - # If we have it already, that's odd, but merge them - if (grep { $_ eq $tuple } @check) { - $ARGSref->{$key} = [ - (ref $ARGSref->{$key} eq 'ARRAY' ? @{$ARGSref->{$key}} : $ARGSref->{$key}), - (ref $ARGSref->{$arg} eq 'ARRAY' ? @{$ARGSref->{$arg}} : $ARGSref->{$arg}), - ]; - } else { - $ARGSref->{$key} = $ARGSref->{$arg}; - push @check, $tuple; - } - } - } - - # Build our rights state for each Principal-Object tuple - foreach my $arg ( keys %$ARGSref ) { - next unless $arg =~ /^SetRights-(\d+-.+?-\d+)$/; - - my $tuple = $1; - my $value = $ARGSref->{$arg}; - my @rights = grep { $_ } (ref $value eq 'ARRAY' ? @$value : $value); - next unless @rights; - - $state{$tuple} = { map { $_ => 1 } @rights }; - } - - foreach my $tuple (List::MoreUtils::uniq @check) { - next unless $tuple =~ /^(\d+)-(.+?)-(\d+)$/; - - my ( $principal_id, $object_type, $object_id ) = ( $1, $2, $3 ); - - 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; - } - } 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; - } - - my $acls = RT::ACL->new($session{'CurrentUser'}); - $acls->LimitToObject( $obj ); - $acls->LimitToPrincipal( Id => $principal_id ); - - while ( my $ace = $acls->Next ) { - my $right = $ace->RightName; - - # Has right and should have right - next if delete $state{$tuple}->{$right}; - - # Has right and shouldn't have right - my ($val, $msg) = $principal->RevokeRight( Object => $obj, Right => $right ); - push @results, $msg; - } - - # For everything left, they don't have the right but they should - for my $right (keys %{ $state{$tuple} || {} }) { - delete $state{$tuple}->{$right}; - my ($val, $msg) = $principal->GrantRight( Object => $obj, Right => $right ); - push @results, $msg; - } - - # Check our state for leftovers - if ( keys %{ $state{$tuple} || {} } ) { - my $missed = join '|', %{$state{$tuple} || {}}; - $RT::Logger->warn( - "Uh-oh, it looks like we somehow missed a right in " - ."ProcessACLs. Here's what was leftover: $missed" - ); - } - } - - return (@results); -} - -=head2 _ParseACLNewPrincipal - -Takes a hashref of C<%ARGS> and a principal type (C or C). Looks -for the presence of rights being added on a principal of the specified type, -and returns undef if no new principal is being granted rights. Otherwise loads -up an L or L object and returns it. Note that the object -may not be successfully loaded, and you should check C<->id> yourself. - -=cut - -sub _ParseACLNewPrincipal { - my $ARGSref = shift; - my $type = lc shift; - my $key = "AddPrincipalForRights-$type"; - - return unless $ARGSref->{$key}; - - my $principal; - if ( $type eq 'user' ) { - $principal = RT::User->new( $session{'CurrentUser'} ); - $principal->LoadByCol( Name => $ARGSref->{$key} ); - } - elsif ( $type eq 'group' ) { - $principal = RT::Group->new( $session{'CurrentUser'} ); - $principal->LoadUserDefinedGroup( $ARGSref->{$key} ); - } - return $principal; -} - - -=head2 UpdateRecordObj ( ARGSRef => \%ARGS, Object => RT::Record, AttributesRef => \@attribs) - -@attribs is a list of ticket fields to check and update if they differ from the B's current values. ARGSRef is a ref to HTML::Mason's %ARGS. - -Returns an array of success/failure messages - -=cut - -sub UpdateRecordObject { - my %args = ( - 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'}, - ); - - return (@results); -} - - - -sub ProcessCustomFieldUpdates { - my %args = ( - CustomFieldObj => undef, - ARGSRef => undef, - @_ - ); - - my $Object = $args{'CustomFieldObj'}; - my $ARGSRef = $args{'ARGSRef'}; - - my @attribs = qw(Name Type Description Queue SortOrder); - my @results = UpdateRecordObject( - AttributesRef => \@attribs, - Object => $Object, - ARGSRef => $ARGSRef - ); - - my $prefix = "CustomField-" . $Object->Id; - if ( $ARGSRef->{"$prefix-AddValue-Name"} ) { - my ( $addval, $addmsg ) = $Object->AddValue( - Name => $ARGSRef->{"$prefix-AddValue-Name"}, - Description => $ARGSRef->{"$prefix-AddValue-Description"}, - SortOrder => $ARGSRef->{"$prefix-AddValue-SortOrder"}, - ); - push( @results, $addmsg ); - } - - 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 ); - } - - my $vals = $Object->Values(); - 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 ); - } - } - } - - return (@results); -} - - - -=head2 ProcessTicketBasics ( TicketObj => $Ticket, ARGSRef => \%ARGS ); - -Returns an array of results messages. - -=cut - -sub ProcessTicketBasics { - - my %args = ( - TicketObj => undef, - ARGSRef => undef, - @_ - ); - - my $TicketObj = $args{'TicketObj'}; - my $ARGSRef = $args{'ARGSRef'}; - - my $OrigOwner = $TicketObj->Owner; - - # Set basic fields - my @attribs = qw( - Subject - FinalPriority - Priority - TimeEstimated - TimeWorked - TimeLeft - Type - Status - Queue - ); - - # Canonicalize Queue and Owner to their IDs if they aren't numeric - for my $field (qw(Queue Owner)) { - if ( $ARGSRef->{$field} and ( $ARGSRef->{$field} !~ /^(\d+)$/ ) ) { - my $class = $field eq 'Owner' ? "RT::User" : "RT::$field"; - my $temp = $class->new(RT->SystemUser); - $temp->Load( $ARGSRef->{$field} ); - if ( $temp->id ) { - $ARGSRef->{$field} = $temp->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'}; - - my @results = UpdateRecordObject( - AttributesRef => \@attribs, - Object => $TicketObj, - ARGSRef => $ARGSRef, - ); - - # We special case owner changing, so we can use ForceOwnerChange - if ( $ARGSRef->{'Owner'} - && $ARGSRef->{'Owner'} !~ /\D/ - && ( $OrigOwner != $ARGSRef->{'Owner'} ) ) { - my ($ChownType); - if ( $ARGSRef->{'ForceOwnerChange'} ) { - $ChownType = "Force"; - } - else { - $ChownType = "Set"; - } - - my ( $val, $msg ) = $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType ); - push( @results, $msg ); - } - - # }}} - - return (@results); -} - -sub ProcessTicketReminders { - my %args = ( - TicketObj => undef, - ARGSRef => undef, - @_ - ); - - my $Ticket = $args{'TicketObj'}; - my $args = $args{'ARGSRef'}; - my @results; - - my $reminder_collection = $Ticket->Reminders->Collection; - - if ( $args->{'update-reminders'} ) { - while ( my $reminder = $reminder_collection->Next ) { - my $resolve_status = $reminder->QueueObj->Lifecycle->ReminderStatusOnResolve; - if ( $reminder->Status ne $resolve_status && $args->{ 'Complete-Reminder-' . $reminder->id } ) { - my ($status, $msg) = $Ticket->Reminders->Resolve($reminder); - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); - - } - elsif ( $reminder->Status eq $resolve_status && !$args->{ 'Complete-Reminder-' . $reminder->id } ) { - my ($status, $msg) = $Ticket->Reminders->Open($reminder); - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); - } - - if ( exists( $args->{ 'Reminder-Subject-' . $reminder->id } ) && ( $reminder->Subject ne $args->{ 'Reminder-Subject-' . $reminder->id } )) { - my ($status, $msg) = $reminder->SetSubject( $args->{ 'Reminder-Subject-' . $reminder->id } ) ; - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); - } - - if ( exists( $args->{ 'Reminder-Owner-' . $reminder->id } ) && ( $reminder->Owner != $args->{ 'Reminder-Owner-' . $reminder->id } )) { - my ($status, $msg) = $reminder->SetOwner( $args->{ 'Reminder-Owner-' . $reminder->id } , "Force" ) ; - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); - } - - if ( exists( $args->{ 'Reminder-Due-' . $reminder->id } ) && $args->{ 'Reminder-Due-' . $reminder->id } ne '' ) { - my $DateObj = RT::Date->new( $session{'CurrentUser'} ); - $DateObj->Set( - Format => 'unknown', - Value => $args->{ 'Reminder-Due-' . $reminder->id } - ); - if ( defined $DateObj->Unix && $DateObj->Unix != $reminder->DueObj->Unix ) { - my ($status, $msg) = $reminder->SetDue( $DateObj->ISO ); - push @results, loc("Reminder #[_1]: [_2]", $reminder->id, $msg); - } - } - } - } - - if ( $args->{'NewReminder-Subject'} ) { - my $due_obj = RT::Date->new( $session{'CurrentUser'} ); - $due_obj->Set( - Format => 'unknown', - Value => $args->{'NewReminder-Due'} - ); - my ( $add_id, $msg ) = $Ticket->Reminders->Add( - Subject => $args->{'NewReminder-Subject'}, - Owner => $args->{'NewReminder-Owner'}, - Due => $due_obj->ISO - ); - if ( $add_id ) { - push @results, loc("Reminder '[_1]' added", $args->{'NewReminder-Subject'}); - } - else { - push @results, $msg; - } - } - return @results; -} - -sub ProcessTicketCustomFieldUpdates { - my %args = @_; - $args{'Object'} = delete $args{'TicketObj'}; - my $ARGSRef = { %{ $args{'ARGSRef'} } }; - - # 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+-.*)/ ) { - $ARGSRef->{"Object-RT::Ticket-$1"} = delete $ARGSRef->{$arg}; - } elsif ( $arg =~ /^CustomField-(\d+-.*)/ ) { - $ARGSRef->{"Object-RT::Ticket--$1"} = delete $ARGSRef->{$arg}; - } elsif ( $arg =~ /^Object-RT::Transaction-(\d*)-CustomField/ ) { - delete $ARGSRef->{$arg}; # don't try to update transaction fields - } - } - - return ProcessObjectCustomFieldUpdates( %args, ARGSRef => $ARGSRef ); -} - -sub ProcessObjectCustomFieldUpdates { - 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---CustomField-- - 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}; - } - - # For each of those objects - foreach my $class ( keys %custom_fields_to_mod ) { - 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; - unless ( $Object->id ) { - $RT::Logger->warning("Couldn't load object $class #$id"); - next; - } - - foreach my $cf ( keys %{ $custom_fields_to_mod{$class}{$id} } ) { - my $CustomFieldObj = RT::CustomField->new( $session{'CurrentUser'} ); - $CustomFieldObj->SetContextObject($Object); - $CustomFieldObj->LoadById($cf); - unless ( $CustomFieldObj->id ) { - $RT::Logger->warning("Couldn't load custom field #$cf"); - next; - } - push @results, - _ProcessObjectCustomFieldUpdates( - Prefix => "Object-$class-$id-CustomField-$cf-", - Object => $Object, - CustomField => $CustomFieldObj, - ARGS => $custom_fields_to_mod{$class}{$id}{$cf}, - ); - } - } - } - return @results; -} - -sub _ProcessObjectCustomFieldUpdates { - 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'} } ) { - - # skip category argument - next if $arg eq 'Category'; - - # and TimeUnits - next if $arg eq 'Value-TimeUnits'; - - # 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 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)$/; - - $arg = 'Values'; - $args{'ARGS'}->{'Values'} = undef; - } - - my @values = (); - if ( ref $args{'ARGS'}->{$arg} eq 'ARRAY' ) { - @values = @{ $args{'ARGS'}->{$arg} }; - } 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 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) { - my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( - Field => $cf->id, - Value => $value - ); - push( @results, $msg ); - } - } 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'}->DeleteCustomFieldValue( - Field => $cf, - Value => $value, - ); - push( @results, $msg ); - } - } elsif ( $arg eq 'DeleteValueIds' ) { - foreach my $value (@values) { - my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue( - Field => $cf, - ValueId => $value, - ); - push( @results, $msg ); - } - } elsif ( $arg eq 'Values' && !$cf->Repeated ) { - my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id ); - - my %values_hash; - 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 ); - $values_hash{$val} = 1 if $val; - } - - # For Date Cfs, @values is empty when there is no changes (no datas in form input) - return @results if ( $cf->Type eq 'Date' && ! @values ); - - # For Date Cfs, @values is empty when there is no changes (no datas in form input) - return @results if ( $cf->Type =~ /^Date(?:Time)?$/ && ! @values ); - - $cf_values->RedoSearch; - while ( my $cf_value = $cf_values->Next ) { - next if $values_hash{ $cf_value->id }; - - my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue( - Field => $cf, - ValueId => $cf_value->id - ); - push( @results, $msg ); - } - } 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] ) { - shift @values; - next; - } - - $delete_flag ||= 1; - $old_cf->Delete; - } - - # now add/replace extra things, if any - foreach my $value (@values) { - my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( - Field => $cf, - Value => $value - ); - push( @results, $msg ); - } - } 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 - ) - ); - } - } - return @results; -} - - -=head2 ProcessTicketWatchers ( TicketObj => $Ticket, ARGSRef => \%ARGS ); - -Returns an array of results messages. - -=cut - -sub ProcessTicketWatchers { - my %args = ( - TicketObj => undef, - ARGSRef => undef, - @_ - ); - my (@results); - - my $Ticket = $args{'TicketObj'}; - my $ARGSRef = $args{'ARGSRef'}; - - # Munge watchers - - foreach my $key ( keys %$ARGSRef ) { - - # Delete deletable watchers - if ( $key =~ /^Ticket-DeleteWatcher-Type-(.*)-Principal-(\d+)$/ ) { - my ( $code, $msg ) = $Ticket->DeleteWatcher( - PrincipalId => $2, - Type => $1 - ); - push @results, $msg; - } - - # Delete watchers in the simple style demanded by the bulk manipulator - elsif ( $key =~ /^Delete(Requestor|Cc|AdminCc)$/ ) { - my ( $code, $msg ) = $Ticket->DeleteWatcher( - Email => $ARGSRef->{$key}, - Type => $1 - ); - push @results, $msg; - } - - # Add new wathchers by email address - elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor)$/ - and $key =~ /^WatcherTypeEmail(\d*)$/ ) - { - - #They're in this order because otherwise $1 gets clobbered :/ - my ( $code, $msg ) = $Ticket->AddWatcher( - Type => $ARGSRef->{$key}, - Email => $ARGSRef->{ "WatcherAddressEmail" . $1 } - ); - push @results, $msg; - } - - #Add requestors in the simple style demanded by the bulk manipulator - elsif ( $key =~ /^Add(Requestor|Cc|AdminCc)$/ ) { - my ( $code, $msg ) = $Ticket->AddWatcher( - Type => $1, - Email => $ARGSRef->{$key} - ); - push @results, $msg; - } - - # Add new watchers by owner - elsif ( $key =~ /^Ticket-AddWatcher-Principal-(\d*)$/ ) { - my $principal_id = $1; - my $form = $ARGSRef->{$key}; - foreach my $value ( ref($form) ? @{$form} : ($form) ) { - next unless $value =~ /^(?:AdminCc|Cc|Requestor)$/i; - - my ( $code, $msg ) = $Ticket->AddWatcher( - Type => $value, - PrincipalId => $principal_id - ); - push @results, $msg; - } - } - - } - return (@results); -} - - - -=head2 ProcessTicketDates ( TicketObj => $Ticket, ARGSRef => \%ARGS ); - -Returns an array of results messages. - -=cut - -sub ProcessTicketDates { - my %args = ( - TicketObj => undef, - ARGSRef => undef, - @_ - ); - - my $Ticket = $args{'TicketObj'}; - my $ARGSRef = $args{'ARGSRef'}; - - my (@results); - - # Set date fields - my @date_fields = qw( - Told - Resolved - Starts - Started - Due - WillResolve - ); - - #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' } - ); - - 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"; - } - } - - # }}} - return (@results); -} - - - -=head2 ProcessTicketLinks ( TicketObj => $Ticket, ARGSRef => \%ARGS ); - -Returns an array of results messages. - -=cut - -sub ProcessTicketLinks { - my %args = ( - TicketObj => undef, - ARGSRef => undef, - @_ - ); - - my $Ticket = $args{'TicketObj'}; - my $ARGSRef = $args{'ARGSRef'}; - - my (@results) = ProcessRecordLinks( RecordObj => $Ticket, ARGSRef => $ARGSRef ); - - #Merge if we need to - if ( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ) { - $ARGSRef->{ $Ticket->Id . "-MergeInto" } =~ s/\s+//g; - my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ); - push @results, $msg; - } - - return (@results); -} - - -sub ProcessRecordLinks { - my %args = ( - RecordObj => undef, - ARGSRef => undef, - @_ - ); - - my $Record = $args{'RecordObj'}; - my $ARGSRef = $args{'ARGSRef'}; - - my (@results); - - # Delete links that are gone gone gone. - foreach my $arg ( keys %$ARGSRef ) { - if ( $arg =~ /DeleteLink-(.*?)-(DependsOn|MemberOf|RefersTo)-(.*)$/ ) { - my $base = $1; - my $type = $2; - my $target = $3; - - my ( $val, $msg ) = $Record->DeleteLink( - Base => $base, - Type => $type, - Target => $target - ); - - push @results, $msg; - - } - - } - - my @linktypes = qw( DependsOn MemberOf RefersTo ); - - foreach my $linktype (@linktypes) { - if ( $ARGSRef->{ $Record->Id . "-$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 } ) { - $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 ProcessTransactionSquelching - -Takes a hashref of the submitted form arguments, C<%ARGS>. - -Returns a hash of squelched addresses. - -=cut - -sub ProcessTransactionSquelching { - my $args = shift; - my %checked = map { $_ => 1 } grep { defined } - ( ref $args->{'TxnSendMailTo'} eq "ARRAY" ? @{$args->{'TxnSendMailTo'}} : - defined $args->{'TxnSendMailTo'} ? ($args->{'TxnSendMailTo'}) : - () ); - my %squelched = map { $_ => 1 } grep { not $checked{$_} } split /,/, ($args->{'TxnRecipients'}||''); - return %squelched; -} - -=head2 _UploadedFile ( $arg ); - -Takes a CGI parameter name; if a file is uploaded under that name, -return a hash reference suitable for AddCustomFieldValue's use: -C<( Value => $filename, LargeContent => $content, ContentType => $type )>. - -Returns C 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 $upload_info = $cgi_object->uploadInfo($fh); - - my $filename = "$fh"; - $filename =~ s#^.*[\\/]##; - binmode($fh); - - return { - Value => $filename, - LargeContent => do { local $/; scalar <$fh> }, - 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'} =~ /^(.*?)\.(.+)$/ ) { - $subkey =~ s/^\{(.*)\}$/$1/; - 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 GetPrincipalsMap OBJECT, CATEGORIES - -Returns an array suitable for passing to /Admin/Elements/EditRights with the -principal collections mapped from the categories given. - -=cut - -sub GetPrincipalsMap { - my $object = shift; - my @map; - for (@_) { - if (/System/) { - my $system = RT::Groups->new($session{'CurrentUser'}); - $system->LimitToSystemInternalGroups(); - $system->OrderBy( FIELD => 'Type', ORDER => 'ASC' ); - push @map, [ - 'System' => $system, # loc_left_pair - 'Type' => 1, - ]; - } - elsif (/Groups/) { - my $groups = RT::Groups->new($session{'CurrentUser'}); - $groups->LimitToUserDefinedGroups(); - $groups->OrderBy( FIELD => 'Name', ORDER => 'ASC' ); - - # Only show groups who have rights granted on this object - $groups->WithGroupRight( - Right => '', - Object => $object, - IncludeSystemRights => 0, - IncludeSubgroupMembers => 0, - ); - - push @map, [ - 'User Groups' => $groups, # loc_left_pair - 'Name' => 0 - ]; - } - elsif (/Roles/) { - my $roles = RT::Groups->new($session{'CurrentUser'}); - - if ($object->isa('RT::System')) { - $roles->LimitToRolesForSystem(); - } - elsif ($object->isa('RT::Queue')) { - $roles->LimitToRolesForQueue($object->Id); - } - else { - $RT::Logger->warn("Skipping unknown object type ($object) for Role principals"); - next; - } - $roles->OrderBy( FIELD => 'Type', ORDER => 'ASC' ); - push @map, [ - 'Roles' => $roles, # loc_left_pair - 'Type' => 1 - ]; - } - elsif (/Users/) { - my $Users = RT->PrivilegedUsers->UserMembersObj(); - $Users->OrderBy( FIELD => 'Name', ORDER => 'ASC' ); - - # Only show users who have rights granted on this object - my $group_members = $Users->WhoHaveGroupRight( - Right => '', - Object => $object, - IncludeSystemRights => 0, - IncludeSubgroupMembers => 0, - ); - - # Limit to UserEquiv groups - my $groups = $Users->NewAlias('Groups'); - $Users->Join( - ALIAS1 => $groups, - FIELD1 => 'id', - ALIAS2 => $group_members, - FIELD2 => 'GroupId' - ); - $Users->Limit( ALIAS => $groups, FIELD => 'Domain', VALUE => 'ACLEquivalence' ); - $Users->Limit( ALIAS => $groups, FIELD => 'Type', VALUE => 'UserEquiv' ); - - - my $display = sub { - $m->scomp('/Elements/ShowUser', User => $_[0], NoEscape => 1) - }; - push @map, [ - 'Users' => $Users, # loc_left_pair - $display => 0 - ]; - } - } - return @map; -} - -=head2 _load_container_object ( $type, $id ); - -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 ); -} - -=head2 _parse_saved_search ( $arg ); - -Given a serialization string for saved search, and returns the -container object and the search id. - -=cut - -sub _parse_saved_search { - my $spec = shift; - return unless $spec; - 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 ); -} - -=head2 ScrubHTML content - -Removes unsafe and undesired HTML from the passed content - -=cut - -my $SCRUBBER; -sub ScrubHTML { - my $Content = shift; - $SCRUBBER = _NewScrubber() unless $SCRUBBER; - - $Content = '' if !defined($Content); - return $SCRUBBER->scrub($Content); -} - -=head2 _NewScrubber - -Returns a new L object. - -If you need to be more lax about what HTML tags and attributes are allowed, -create C with something like the -following: - - package HTML::Mason::Commands; - # Let tables through - push @SCRUBBER_ALLOWED_TAGS, qw(TABLE THEAD TBODY TFOOT TR TD TH); - 1; - -=cut - -our @SCRUBBER_ALLOWED_TAGS = qw( - A B U P BR I HR BR SMALL EM FONT SPAN STRONG SUB SUP STRIKE H1 H2 H3 H4 H5 - H6 DIV UL OL LI DL DT DD PRE BLOCKQUOTE BDO -); - -our %SCRUBBER_ALLOWED_ATTRIBUTES = ( - # Match http, https, ftp, mailto and relative urls - # XXX: we also scrub format strings with this module then allow simple config options - href => qr{^(?:https?:|ftp:|mailto:|/|__Web(?:Path|BaseURL|URL)__)}i, - face => 1, - size => 1, - target => 1, - style => qr{ - ^(?:\s* - (?:(?:background-)?color: \s* - (?:rgb\(\s* \d+, \s* \d+, \s* \d+ \s*\) | # rgb(d,d,d) - \#[a-f0-9]{3,6} | # #fff or #ffffff - [\w\-]+ # green, light-blue, etc. - ) | - text-align: \s* \w+ | - font-size: \s* [\w.\-]+ | - font-family: \s* [\w\s"',.\-]+ | - font-weight: \s* [\w\-]+ | - - # MS Office styles, which are probably fine. If we don't, then any - # associated styles in the same attribute get stripped. - mso-[\w\-]+?: \s* [\w\s"',.\-]+ - )\s* ;? \s*) - +$ # one or more of these allowed properties from here 'till sunset - }ix, - dir => qr/^(rtl|ltr)$/i, - lang => qr/^\w+(-\w+)?$/, -); - -our %SCRUBBER_RULES = (); - -sub _NewScrubber { - require HTML::Scrubber; - my $scrubber = HTML::Scrubber->new(); - $scrubber->default( - 0, - { - %SCRUBBER_ALLOWED_ATTRIBUTES, - '*' => 0, # require attributes be explicitly allowed - }, - ); - $scrubber->deny(qw[*]); - $scrubber->allow(@SCRUBBER_ALLOWED_TAGS); - $scrubber->rules(%SCRUBBER_RULES); - - # Scrubbing comments is vital since IE conditional comments can contain - # arbitrary HTML and we'd pass it right on through. - $scrubber->comment(0); - - return $scrubber; -} - -=head2 JSON - -Redispatches to L - -=cut - -sub JSON { - RT::Interface::Web::EncodeJSON(@_); -} - -package RT::Interface::Web; -RT::Base->_ImportOverlays(); - -1; diff --git a/rt/lib/RT/Interface/Web/Handler.pm b/rt/lib/RT/Interface/Web/Handler.pm index fc95aa10e..8667f014d 100644 --- a/rt/lib/RT/Interface/Web/Handler.pm +++ b/rt/lib/RT/Interface/Web/Handler.pm @@ -54,7 +54,6 @@ use CGI qw/-private_tempfiles/; use MIME::Entity; use Text::Wrapper; use CGI::Cookie; -use Time::ParseDate; use Time::HiRes; use HTML::Scrubber; use RT::Interface::Web; @@ -62,6 +61,9 @@ use RT::Interface::Web::Request; use File::Path qw( rmtree ); use File::Glob qw( bsd_glob ); use File::Spec::Unix; +use HTTP::Message::PSGI; +use HTTP::Request; +use HTTP::Response; sub DefaultHandlerArgs { ( comp_root => [ @@ -104,7 +106,6 @@ sub InitSessionDir { } -use UNIVERSAL::require; sub NewHandler { my $class = shift; $class->require or die $!; @@ -114,7 +115,7 @@ sub NewHandler { @_ ); - $handler->interp->set_escape( h => \&RT::Interface::Web::EscapeUTF8 ); + $handler->interp->set_escape( h => \&RT::Interface::Web::EscapeHTML ); $handler->interp->set_escape( u => \&RT::Interface::Web::EscapeURI ); $handler->interp->set_escape( j => \&RT::Interface::Web::EscapeJS ); return($handler); @@ -154,7 +155,7 @@ and is not recommended to change. =item Clean up state of RT::Action::SendEmail using 'CleanSlate' method -=item Flush tmp GnuPG key preferences +=item Flush tmp crypt key preferences =back @@ -181,10 +182,9 @@ sub CleanupRequest { 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 ); + if (RT->Config->Get('Crypt')->{'Enable'}) { + RT::Crypt->UseKeyForEncryption(); + RT::Crypt->UseKeyForSigning( undef ); } %RT::Ticket::MERGE_CACHE = ( effective => {}, merged => {} ); @@ -248,6 +248,7 @@ MODPERL use RT::Interface::Web::Handler; use CGI::Emulate::PSGI; +use Plack::Builder; use Plack::Request; use Plack::Response; use Plack::Util; @@ -262,7 +263,7 @@ sub PSGIApp { $self->InitSessionDir; - return sub { + my $mason = sub { my $env = shift; { @@ -270,7 +271,14 @@ sub PSGIApp { return $self->_psgi_response_cb( $res->finalize ) if $res; } - RT::ConnectToDatabase() unless RT->InstallMode; + unless (RT->InstallMode) { + unless (eval { RT::ConnectToDatabase() }) { + my $res = Plack::Response->new(503); + $res->content_type("text/plain"); + $res->body("Database inaccessible; contact the RT administrator (".RT->Config->Get("OwnerEmail").")"); + return $self->_psgi_response_cb( $res->finalize, sub { $self->CleanupRequest } ); + } + } my $req = Plack::Request->new($env); @@ -307,7 +315,59 @@ sub PSGIApp { sub { $self->CleanupRequest() }); -}; + }; + + my $app = $self->StaticWrap($mason); + for my $plugin (RT->Config->Get("Plugins")) { + my $wrap = $plugin->can("PSGIWrap") + or next; + $app = $wrap->($plugin, $app); + } + return $app; +} + +sub StaticWrap { + my $self = shift; + my $app = shift; + my $builder = Plack::Builder->new; + + my $headers = RT::Interface::Web::GetStaticHeaders(Time => 'forever'); + + for my $static ( RT->Config->Get('StaticRoots') ) { + if ( ref $static && ref $static eq 'HASH' ) { + $builder->add_middleware( + '+RT::Interface::Web::Middleware::StaticHeaders', + path => $static->{'path'}, + headers => $headers, + ); + $builder->add_middleware( + 'Plack::Middleware::Static', + pass_through => 1, + %$static + ); + } + else { + $RT::Logger->error( + "Invalid config StaticRoots: item can only be a hashref" ); + } + } + + my $path = sub { s!^/static/!! }; + $builder->add_middleware( + '+RT::Interface::Web::Middleware::StaticHeaders', + path => $path, + headers => $headers, + ); + for my $root (RT::Interface::Web->StaticRoots) { + $builder->add_middleware( + 'Plack::Middleware::Static', + path => $path, + root => $root, + pass_through => 1, + ); + } + return $builder->to_app($app); +} sub _psgi_response_cb { my $self = shift; @@ -334,7 +394,19 @@ sub _psgi_response_cb { return $_[0]; }; }); - } +} + +sub GetStatic { + my $class = shift; + my $path = shift; + my $static = $class->StaticWrap( + # Anything the static wrap doesn't handle gets 404'd. + sub { [404, [], []] } + ); + my $response = HTTP::Response->from_psgi( + $static->( HTTP::Request->new(GET => $path)->to_psgi ) + ); + return $response; } 1; diff --git a/rt/lib/RT/Interface/Web/Menu.pm b/rt/lib/RT/Interface/Web/Menu.pm index 03ce8ac5a..8670b8acf 100644 --- a/rt/lib/RT/Interface/Web/Menu.pm +++ b/rt/lib/RT/Interface/Web/Menu.pm @@ -57,7 +57,7 @@ use URI; use Scalar::Util qw(weaken); __PACKAGE__->mk_accessors(qw( - key title description raw_html escape_title sort_order target class + key title description raw_html escape_title sort_order target class attributes )); =head1 NAME @@ -70,9 +70,9 @@ RT::Interface::Web::Menu - Handle the API for menu navigation Creates a new L object. Possible keys in the I are L, L, L, L, -L, L, L, L, L and -L. See the subroutines with the respective name below for -each option's use. +L, L, L, L, L, +L, and L. See the subroutines with the respective name +below for each option's use. =cut @@ -139,6 +139,12 @@ Get or set the frame or pseudo-target for this link. something like L<_blank> Gets or sets the CSS class the menu item should have in addition to the default classes. This is only used if L isn't specified. +=head2 attributes [HASHREF] + +Gets or sets a hashref of HTML attribute name-value pairs that the menu item +should have in addition to the attributes which have their own accessor, like +L and L. This is only used if L isn't specified. + =head2 path Gets or sets the URL that the menu's link goes to. If the link diff --git a/rt/lib/RT/Interface/Web/Middleware/StaticHeaders.pm b/rt/lib/RT/Interface/Web/Middleware/StaticHeaders.pm new file mode 100644 index 000000000..6d98d9ecd --- /dev/null +++ b/rt/lib/RT/Interface/Web/Middleware/StaticHeaders.pm @@ -0,0 +1,80 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC +# +# +# (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::Middleware::StaticHeaders; + +use strict; +use warnings; +use base 'Plack::Middleware'; +use Plack::Util; + +use Plack::Util::Accessor qw(path headers); + +sub call { + my ( $self, $env ) = @_; + my $res = $self->app->($env); + my $path_match = $self->path; + my $path = $env->{'PATH_INFO'}; + for ($path) { + my $matched = 'CODE' eq ref $path_match ? + $path_match->($_, $env) + : $_ =~ $path_match; + return $res unless $matched; + return $self->response_cb( $res, + sub { + my $res = shift; + my $headers = $res->[1]; + Plack::Util::header_iter( $self->headers, sub { + Plack::Util::header_set($headers, @_); + } ); + } + ); + } +} + +1; diff --git a/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm b/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm index 1da160c8d..d7de61ccf 100755 --- a/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm +++ b/rt/lib/RT/Interface/Web/QueryBuilder/Tree.pm @@ -113,9 +113,7 @@ sub GetReferencedQueues { return unless $clause->{Key} eq 'Queue'; return unless $clause->{Op} eq '='; - my $value = $clause->{Value}; - $value =~ s/\\(.)/$1/g if $value =~ s/^'(.*)'$/$1/; - $queues->{ $value } = 1; + $queues->{ $clause->{RawValue} } = 1; } ); @@ -257,6 +255,7 @@ sub ParseSQL { $callback{'EntryAggregator'} = sub { $node->setNodeValue( $_[0] ) }; $callback{'Condition'} = sub { my ($key, $op, $value) = @_; + my $rawvalue = $value; my ($main_key) = split /[.]/, $key; @@ -281,7 +280,7 @@ sub ParseSQL { $key = "'$key'"; } - my $clause = { Key => $key, Op => $op, Value => $value }; + my $clause = { Key => $key, Op => $op, Value => $value, RawValue => $rawvalue }; $node->addChild( __PACKAGE__->new( $clause ) ); }; $callback{'Error'} = sub { push @results, @_ }; diff --git a/rt/lib/RT/Interface/Web/Request.pm b/rt/lib/RT/Interface/Web/Request.pm index 7a246a32b..61cfa70ff 100644 --- a/rt/lib/RT/Interface/Web/Request.pm +++ b/rt/lib/RT/Interface/Web/Request.pm @@ -51,7 +51,6 @@ package RT::Interface::Web::Request; use strict; use warnings; -our $VERSION = '0.30'; use HTML::Mason::PSGIHandler; use base qw(HTML::Mason::Request::PSGI); use Params::Validate qw(:all); @@ -65,8 +64,6 @@ sub new { =head2 callback -Method replaces deprecated component C. - Takes hash with optional C, C and C arguments, other arguments are passed throught to callback components. diff --git a/rt/lib/RT/Interface/Web/Session.pm b/rt/lib/RT/Interface/Web/Session.pm index d854130b6..c4cc93080 100644 --- a/rt/lib/RT/Interface/Web/Session.pm +++ b/rt/lib/RT/Interface/Web/Session.pm @@ -84,8 +84,7 @@ sub Class { my $class = RT->Config->Get('WebSessionClass') || $self->Backends->{RT->Config->Get('DatabaseType')} || 'Apache::Session::File'; - eval "require $class"; - die $@ if $@; + $class->require or die "Can't load $class: $@"; return $class; } @@ -98,8 +97,9 @@ sessions class names as values. sub Backends { return { - mysql => 'Apache::Session::MySQL', - Pg => 'Apache::Session::Postgres', + mysql => 'Apache::Session::MySQL', + Pg => 'Apache::Session::Postgres', + Oracle => 'Apache::Session::Oracle', }; } @@ -112,15 +112,27 @@ new session objects. sub Attributes { my $class = $_[0]->Class; - return !$class->isa('Apache::Session::File') ? { - Handle => $RT::Handle->dbh, - LockHandle => $RT::Handle->dbh, - Transaction => 1, - } : { + my $res; + if ( my %props = RT->Config->Get('WebSessionProperties') ) { + $res = \%props; + } + elsif ( $class->isa('Apache::Session::File') ) { + $res = { Directory => $RT::MasonSessionDir, LockDirectory => $RT::MasonSessionDir, Transaction => 1, }; + } + else { + $res = { + Handle => $RT::Handle->dbh, + LockHandle => $RT::Handle->dbh, + Transaction => 1, + }; + } + $res->{LongReadLen} = RT->Config->Get('MaxAttachmentSize') + if $class->isa('Apache::Session::Oracle'); + return $res; } =head3 Ids -- cgit v1.2.1