X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=rt%2Flib%2FRT%2FInterface%2FWeb.pm;h=d6b854f4ed0e1f3ac17385d164f081ad23e36eb4;hb=7b125e587a4d1ee0aca692e23ea7897f671855ae;hp=6b5272848ca6bc2aa99b6e036da3615ab714f073;hpb=3ef62a0570055da710328937e7f65dbb2c027c62;p=freeside.git diff --git a/rt/lib/RT/Interface/Web.pm b/rt/lib/RT/Interface/Web.pm index 6b5272848..d6b854f4e 100644 --- a/rt/lib/RT/Interface/Web.pm +++ b/rt/lib/RT/Interface/Web.pm @@ -1,140 +1,878 @@ -## $Header: /home/cvs/cvsroot/freeside/rt/lib/RT/Interface/Web.pm,v 1.1 2002-08-12 06:17:08 ivan Exp $ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2009 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 -## Copyright 1996-2002 Jesse Vincent ## 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; -# {{{ sub NewParser +use RT::SavedSearches; +use URI qw(); +use RT::Interface::Web::Session; +use Digest::MD5 (); +use Encode qw(); -=head2 NewParser +# {{{ EscapeUTF8 - Returns a new Mason::Parser object. Takes a param hash of things - that get passed to HTML::Mason::Parser. Currently hard coded to only - take the parameter 'allow_globals'. +=head2 EscapeUTF8 SCALARREF -=cut +does a css-busting but minimalist escaping of whatever html you're passing in. -sub NewParser { - my %args = ( - allow_globals => undef, - @_ - ); +=cut - my $parser = new HTML::Mason::Parser( - default_escape_flags => 'h', - allow_globals => $args{'allow_globals'} - ); - return ($parser); +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; } # }}} -# {{{ sub NewInterp +# {{{ EscapeURI -=head2 NewInterp +=head2 EscapeURI SCALARREF - Takes a paremeter hash. Needs a param called 'parser' which is a reference - to an HTML::Mason::Parser. - returns a new Mason::Interp object +Escapes URI component according to RFC2396 =cut -sub NewInterp { - my %params = ( - comp_root => [ - [ local => $RT::MasonLocalComponentRoot ], - [ standard => $RT::MasonComponentRoot ] - ], - data_dir => "$RT::MasonDataDir", - @_ - ); +sub EscapeURI { + my $ref = shift; + return unless defined $$ref; + + use bytes; + $$ref =~ s/([^a-zA-Z0-9_.!~*'()-])/uc sprintf("%%%02X", ord($1))/eg; +} - #We allow recursive autohandlers to allow for RT auth. +# }}} - use HTML::Mason::Interp; - my $interp = new HTML::Mason::Interp(%params); +# {{{ WebCanonicalizeInfo +=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'}; } # }}} -# {{{ sub NewApacheHandler +# {{{ WebExternalAutoInfo -=head2 NewApacheHandler +=head2 WebExternalAutoInfo($user); - Takes a Mason::Interp object - Returns a new Mason::ApacheHandler object +Returns a hash of user attributes, used when WebExternalAuto is set. =cut -sub NewApacheHandler { - my $interp = shift; - my $ah = new HTML::Mason::ApacheHandler( interp => $interp ); - return ($ah); +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; + + $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8"); + + $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ]; + + # Roll back any dangling transactions from a previous failed connection + $RT::Handle->ForceRollback() if $RT::Handle->TransactionDepth; + + MaybeEnableSQLStatementLog(); + + # avoid reentrancy, as suggested by masonbook + local *HTML::Mason::Commands::session unless $HTML::Mason::Commands::m->is_subrequest; + + $HTML::Mason::Commands::m->autoflush( $HTML::Mason::Commands::m->request_comp->attr('AutoFlush') ) + if ( $HTML::Mason::Commands::m->request_comp->attr_exists('AutoFlush') ); + + DecodeARGS($ARGS); + PreprocessTimeUpdates($ARGS); + + MaybeShowInstallModePage(); -# {{{ sub NewMason11ApacheHandler + $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS ); + SendSessionCookie(); + $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new() unless _UserLoggedIn(); -=head2 NewMason11ApacheHandler + MaybeShowNoAuthPage($ARGS); - Returns a new Mason::ApacheHandler object + AttemptExternalAuth($ARGS) if RT->Config->Get('WebExternalAuthContinuous') or not _UserLoggedIn(); + + _ForceLogout() unless _UserLoggedIn(); + + # Process per-page authentication callbacks + $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Auth', CallbackPage => '/autohandler' ); + + unless ( _UserLoggedIn() ) { + _ForceLogout(); + + # If the user is logging in, let's authenticate + if ( defined $ARGS->{user} && defined $ARGS->{pass} ) { + AttemptPasswordAuthentication($ARGS); + } else { + # if no credentials then show him login page + $HTML::Mason::Commands::m->comp( '/Elements/Login', %$ARGS ); + $HTML::Mason::Commands::m->abort; + } + } + + # now it applies not only to home page, but any dashboard that can be used as a workspace + $HTML::Mason::Commands::session{'home_refresh_interval'} = $ARGS->{'HomeRefreshInterval'} + if ( $ARGS->{'HomeRefreshInterval'} ); + + # Process per-page global callbacks + $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Default', CallbackPage => '/autohandler' ); + + ShowRequestedPage($ARGS); + LogRecordedSQLStatements(); +} + +sub _ForceLogout { + + delete $HTML::Mason::Commands::session{'CurrentUser'}; +} + +sub _UserLoggedIn { + if ( $HTML::Mason::Commands::session{CurrentUser} && $HTML::Mason::Commands::session{'CurrentUser'}->id ) { + return 1; + } else { + return undef; + } + +} + +=head2 MaybeShowInstallModePage + +This function, called exclusively by RT's autohandler, dispatches +a request to RT's Installation workflow, only if Install Mode is enabled in the configuration file. + +If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler + +=cut + +sub MaybeShowInstallModePage { + return unless RT->InstallMode; + + my $m = $HTML::Mason::Commands::m; + if ( $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex') ) { + $m->call_next(); + } elsif ( $m->request_comp->path !~ '^(/+)Install/' ) { + RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "Install/index.html" ); + } else { + $m->call_next(); + } + $m->abort(); +} + +=head2 MaybeShowNoAuthPage \%ARGS + +This function, called exclusively by RT's autohandler, dispatches +a request to the page a user requested (but only if it matches the "noauth" regex. + +If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler + +=cut + +sub MaybeShowNoAuthPage { + my $ARGS = shift; + + my $m = $HTML::Mason::Commands::m; + + return unless $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex'); + + # If it's a noauth file, don't ask for auth. + SendSessionCookie(); + $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS ); + $m->abort; +} + +=head2 ShowRequestedPage \%ARGS + +This function, called exclusively by RT's autohandler, dispatches +a request to the page a user requested (making sure that unpriviled users +can only see self-service pages. + +=cut + +sub ShowRequestedPage { + my $ARGS = shift; + + my $m = $HTML::Mason::Commands::m; + + SendSessionCookie(); + + # If the user isn't privileged, they can only see SelfService + unless ( $HTML::Mason::Commands::session{'CurrentUser'}->Privileged ) { + + # if the user is trying to access a ticket, redirect them + if ( $m->request_comp->path =~ '^(/+)Ticket/Display.html' && $ARGS->{'id'} ) { + RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/Display.html?id=" . $ARGS->{'id'} ); + } + + # otherwise, drop the user at the SelfService default page + elsif ( $m->base_comp->path !~ RT->Config->Get('SelfServiceRegex') ) { + RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/" ); + } + + # if user is in SelfService dir let him do anything + else { + $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS ); + } + } else { + $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS ); + } + +} + +sub AttemptExternalAuth { + my $ARGS = shift; + + return unless ( RT->Config->Get('WebExternalAuth') ); + + my $user = $ARGS->{user}; + my $m = $HTML::Mason::Commands::m; + + # If RT is configured for external auth, let's go through and get REMOTE_USER + + # do we actually have a REMOTE_USER equivlent? + if ( RT::Interface::Web::WebCanonicalizeInfo() ) { + my $orig_user = $user; + + $user = RT::Interface::Web::WebCanonicalizeInfo(); + my $load_method = RT->Config->Get('WebExternalGecos') ? 'LoadByGecos' : 'Load'; + + if ( $^O eq 'MSWin32' and RT->Config->Get('WebExternalGecos') ) { + my $NodeName = Win32::NodeName(); + $user =~ s/^\Q$NodeName\E\\//i; + } + + InstantiateNewSession() unless _UserLoggedIn; + $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new(); + $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user); + + if ( RT->Config->Get('WebExternalAuto') and not _UserLoggedIn() ) { + + # Create users on-the-fly + my $UserObj = RT::User->new($RT::SystemUser); + my ( $val, $msg ) = $UserObj->Create( + %{ ref RT->Config->Get('AutoCreate') ? RT->Config->Get('AutoCreate') : {} }, + Name => $user, + Gecos => $user, + ); + + if ($val) { + + # now get user specific information, to better create our user. + my $new_user_info = RT::Interface::Web::WebExternalAutoInfo($user); + + # set the attributes that have been defined. + foreach my $attribute ( $UserObj->WritableAttributes ) { + $m->callback( + Attribute => $attribute, + User => $user, + UserInfo => $new_user_info, + CallbackName => 'NewUser', + CallbackPage => '/autohandler' + ); + my $method = "Set$attribute"; + $UserObj->$method( $new_user_info->{$attribute} ) if defined $new_user_info->{$attribute}; + } + $HTML::Mason::Commands::session{'CurrentUser'}->Load($user); + } else { + + # we failed to successfully create the user. abort abort abort. + delete $HTML::Mason::Commands::session{'CurrentUser'}; + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc( 'Cannot create user: [_1]', $msg ) ) + if RT->Config->Get('WebFallbackToInternalAuth');; + $m->abort(); + } + } + + if ( _UserLoggedIn() ) { + $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' ); + } else { + delete $HTML::Mason::Commands::session{'CurrentUser'}; + $user = $orig_user; + + if ( RT->Config->Get('WebExternalOnly') ) { + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('You are not an authorized user') ); + $m->abort(); + } + } + } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) { + unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) { + # XXX unreachable due to prior defaulting in HandleRequest (check c34d108) + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('You are not an authorized user') ); + $m->abort(); + } + } else { + + # WebExternalAuth is set, but we don't have a REMOTE_USER. abort + # XXX: we must return AUTH_REQUIRED status or we fallback to + # internal auth here too. + delete $HTML::Mason::Commands::session{'CurrentUser'} + if defined $HTML::Mason::Commands::session{'CurrentUser'}; + } +} + +sub AttemptPasswordAuthentication { + my $ARGS = shift; + my $user_obj = RT::CurrentUser->new(); + $user_obj->Load( $ARGS->{user} ); + + my $m = $HTML::Mason::Commands::m; + + unless ( $user_obj->id && $user_obj->IsPassword( $ARGS->{pass} ) ) { + $RT::Logger->error("FAILED LOGIN for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}"); + $m->comp( '/Elements/Login', %$ARGS, Error => HTML::Mason::Commands::loc('Your username or password is incorrect'), ); + $m->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' ); + $m->abort; + } + + $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}"); + InstantiateNewSession(); + $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj; + $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' ); +} + +=head2 LoadSessionFromCookie + +Load or setup a session cookie for the current user. =cut -sub NewMason11ApacheHandler { - my %args = ( default_escape_flags => 'h', - allow_globals => [%session], - comp_root => [ - [ local => $RT::MasonLocalComponentRoot ], - [ standard => $RT::MasonComponentRoot ] - ], - data_dir => "$RT::MasonDataDir", - args_method => 'CGI' +sub _SessionCookieName { + my $cookiename = "RT_SID_" . RT->Config->Get('rtname'); + $cookiename .= "." . $ENV{'SERVER_PORT'} if $ENV{'SERVER_PORT'}; + return $cookiename; +} + +sub LoadSessionFromCookie { + + my %cookies = CGI::Cookie->fetch; + my $cookiename = _SessionCookieName(); + my $SessionCookie = ( $cookies{$cookiename} ? $cookies{$cookiename}->value : undef ); + tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', $SessionCookie; + unless ( $SessionCookie && $HTML::Mason::Commands::session{'_session_id'} eq $SessionCookie ) { + undef $cookies{$cookiename}; + } + if ( int RT->Config->Get('AutoLogoff') ) { + my $now = int( time / 60 ); + my $last_update = $HTML::Mason::Commands::session{'_session_last_update'} || 0; + + if ( $last_update && ( $now - $last_update - RT->Config->Get('AutoLogoff') ) > 0 ) { + InstantiateNewSession(); + } + + # save session on each request when AutoLogoff is turned on + $HTML::Mason::Commands::session{'_session_last_update'} = $now if $now != $last_update; + } +} + +sub InstantiateNewSession { + tied(%HTML::Mason::Commands::session)->delete if tied(%HTML::Mason::Commands::session); + tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', undef; +} + +sub SendSessionCookie { + my $cookie = CGI::Cookie->new( + -name => _SessionCookieName(), + -value => $HTML::Mason::Commands::session{_session_id}, + -path => RT->Config->Get('WebPath'), + -secure => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 ) ); - my $ah = new HTML::Mason::ApacheHandler(%args); - return ($ah); + + $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( RT->Config->Get('WebURL') ); + + # If the user is coming in via a non-canonical + # hostname, don't redirect them to the canonical host, + # it will just upset them (and invalidate their credentials) + # don't do this if $RT::CanoniaclRedirectURLs is true + if ( !RT->Config->Get('CanonicalizeRedirectURLs') + && $uri->host eq $server_uri->host + && $uri->port eq $server_uri->port ) + { + if ( defined $ENV{HTTPS} and $ENV{'HTTPS'} eq 'on' ) { + $uri->scheme('https'); + } else { + $uri->scheme('http'); + } + # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST + $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} ); + $uri->port( $ENV{'SERVER_PORT'} ); + } -# }}} + # not sure why, but on some systems without this call mason doesn't + # set status to 302, but 200 instead and people see blank pages + $HTML::Mason::Commands::r->status(302); + + # Perlbal expects a status message, but Mason's default redirect status + # doesn't provide one. See also rt.cpan.org #36689. + $HTML::Mason::Commands::m->redirect( $uri->canonical, "302 Found" ); + + $HTML::Mason::Commands::m->abort; +} + +=head2 StaticFileHeaders + +Send the browser a few headers to try to get it to (somewhat agressively) +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); + + # make cache public + $HTML::Mason::Commands::r->headers_out->{'Cache-Control'} = 'max-age=259200, public'; + + # Expire things in a month. + $date->Set( Value => time + 30 * 24 * 60 * 60 ); + $HTML::Mason::Commands::r->headers_out->{'Expires'} = $date->RFC2616; + + # if we set 'Last-Modified' then browser request a comp using 'If-Modified-Since' + # request, but we don't handle it and generate full reply again + # Last modified at server start time + # $date->Set( Value => $^T ); + # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616; +} + +=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 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 + RT::Interface::Web::EscapeUTF8( \$sig ); + 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 $log_sql_statements = RT->Config->Get('StatementLog'); + + return unless ($log_sql_statements); + + my @log = $RT::Handle->SQLStatementLog; + $RT::Handle->ClearSQLStatementLog; + for my $stmt (@log) { + my ( $time, $sql, $bind, $duration ) = @{$stmt}; + my @bind; + if ( ref $bind ) { + @bind = @{$bind}; + } else { + + # Older DBIx-SB + $duration = $bind; + } + $RT::Logger->log( + level => $log_sql_statements, + message => "SQL(" + . sprintf( "%.6f", $duration ) + . "s): $sql;" + . ( @bind ? " [ bound values: @{[map{qq|'$_'|} @bind]} ]" : "" ) + ); + } + +} package HTML::Mason::Commands; +use vars qw/$r $m %session/; + +# {{{ loc + +=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]; + } +} + +# }}} + +# {{{ loc_fuzzy + +=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) ); + } +} + +# }}} + # {{{ sub Abort # Error - calls Error and aborts sub Abort { + my $why = shift; + my %args = @_; - if ( $session{'ErrorDocument'} && $session{'ErrorDocumentType'} ) { - SetContentType( $session{'ErrorDocumentType'} ); - $m->comp( $session{'ErrorDocument'}, Why => shift ); + if ( $session{'ErrorDocument'} + && $session{'ErrorDocumentType'} ) + { + $r->content_type( $session{'ErrorDocumentType'} ); + $m->comp( $session{'ErrorDocument'}, Why => $why, %args ); $m->abort; - } - else { - SetContentType('text/html'); - $m->comp( "/Elements/Error", Why => shift ); + } else { + $m->comp( "/Elements/Error", Why => $why, %args ); $m->abort; } } # }}} -# {{{ sub CreateTicket +# {{{ sub CreateTicket =head2 CreateTicket ARGS Create a new ticket, using Mason's %ARGS. returns @results. + =cut sub CreateTicket { @@ -153,73 +891,162 @@ sub CreateTicket { Abort('You have no permission to create tickets in that queue.'); } - my $due = new RT::Date( $session{'CurrentUser'} ); - $due->Set( Format => 'unknown', Value => $ARGS{'Due'} ); - my $starts = new RT::Date( $session{'CurrentUser'} ); - $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} ); + my $due; + if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) { + $due = new RT::Date( $session{'CurrentUser'} ); + $due->Set( Format => 'unknown', Value => $ARGS{'Due'} ); + } + my $starts; + if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) { + $starts = new RT::Date( $session{'CurrentUser'} ); + $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} ); + } - my @Requestors = split ( /,/, $ARGS{'Requestors'} ); - my @Cc = split ( /,/, $ARGS{'Cc'} ); - my @AdminCc = split ( /,/, $ARGS{'AdminCc'} ); + my $sigless = RT::Interface::Web::StripContent( + Content => $ARGS{Content}, + ContentType => $ARGS{ContentType}, + StripSignature => 1, + CurrentUser => $session{'CurrentUser'}, + ); my $MIMEObj = MakeMIMEEntity( - Subject => $ARGS{'Subject'}, - From => $ARGS{'From'}, - Cc => $ARGS{'Cc'}, - Body => $ARGS{'Content'}, - AttachmentFieldName => 'Attach' + Subject => $ARGS{'Subject'}, + From => $ARGS{'From'}, + Cc => $ARGS{'Cc'}, + Body => $sigless, + Type => $ARGS{'ContentType'}, ); + if ( $ARGS{'Attachments'} ) { + my $rv = $MIMEObj->make_multipart; + $RT::Logger->error("Couldn't make multipart message") + if !$rv || $rv !~ /^(?:DONE|ALREADY)$/; + + foreach ( values %{ $ARGS{'Attachments'} } ) { + unless ($_) { + $RT::Logger->error("Couldn't add empty attachemnt"); + next; + } + $MIMEObj->add_part($_); + } + } + + foreach my $argument (qw(Encrypt Sign)) { + $MIMEObj->head->add( + "X-RT-$argument" => Encode::encode_utf8( $ARGS{$argument} ) + ) if defined $ARGS{$argument}; + } + my %create_args = ( - Queue => $ARGS{Queue}, - Owner => $ARGS{Owner}, - InitialPriority => $ARGS{InitialPriority}, - FinalPriority => $ARGS{FinalPriority}, - TimeLeft => $ARGS{TimeLeft}, - TimeWorked => $ARGS{TimeWorked}, - Requestor => \@Requestors, - Cc => \@Cc, - AdminCc => \@AdminCc, - Subject => $ARGS{Subject}, - Status => $ARGS{Status}, - Due => $due->ISO, - Starts => $starts->ISO, + 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 ); - # we need to get any KeywordSelect- fields into %create_args.. - grep { $_ =~ /^KeywordSelect-/ &&{ $create_args{$_} = $ARGS{$_} } } %ARGS; + my @temp_squelch; + foreach my $type (qw(Requestor Cc AdminCc)) { + push @temp_squelch, map $_->address, Email::Address->parse( $create_args{$type} ) + if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] }; - my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args); - unless ( $id && $Trans ) { - Abort($ErrMsg); } - my @linktypes = qw( DependsOn MemberOf RefersTo ); - foreach my $linktype (@linktypes) { - foreach my $luri ( split ( / /, $ARGS{"new-$linktype"} ) ) { - $luri =~ s/\s*$//; # Strip trailing whitespace - my ( $val, $msg ) = $Ticket->AddLink( - Target => $luri, - Type => $linktype - ); - push ( @Actions, $msg ) unless ($val); + if (@temp_squelch) { + require RT::Action::SendEmail; + RT::Action::SendEmail->SquelchMailTo( RT::Action::SendEmail->SquelchMailTo, @temp_squelch ); + } + + if ( $ARGS{'AttachTickets'} ) { + require RT::Action::SendEmail; + RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets, + ref $ARGS{'AttachTickets'} + ? @{ $ARGS{'AttachTickets'} } + : ( $ARGS{'AttachTickets'} ) ); + } + + foreach my $arg ( keys %ARGS ) { + next if $arg =~ /-(?:Magic|Category)$/; + + if ( $arg =~ /^Object-RT::Transaction--CustomField-/ ) { + $create_args{$arg} = $ARGS{$arg}; } - foreach my $luri ( split ( / /, $ARGS{"$linktype-new"} ) ) { - my ( $val, $msg ) = $Ticket->AddLink( - Base => $luri, - Type => $linktype - ); + # Object-RT::Ticket--CustomField-3-Values + elsif ( $arg =~ /^Object-RT::Ticket--CustomField-(\d+)/ ) { + my $cfid = $1; + + my $cf = RT::CustomField->new( $session{'CurrentUser'} ); + $cf->Load($cfid); + unless ( $cf->id ) { + $RT::Logger->error( "Couldn't load custom field #" . $cfid ); + next; + } + + if ( $arg =~ /-Upload$/ ) { + $create_args{"CustomField-$cfid"} = _UploadedFile($arg); + next; + } + + my $type = $cf->Type; - push ( @Actions, $msg ) unless ($val); + 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; } } - push ( @Actions, $ErrMsg ); + # 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 . "." ); + Abort( "No permission to view newly created ticket #" . $Ticket->id . "." ); } return ( $Ticket, @Actions ); @@ -261,382 +1088,231 @@ sub LoadTicket { # {{{ sub ProcessUpdateMessage -sub ProcessUpdateMessage { +=head2 ProcessUpdateMessage - #TODO document what else this takes. - my %args = ( - ARGSRef => undef, - Actions => undef, - TicketObj => undef, - @_ - ); - - #Make the update content have no 'weird' newlines in it - if ( $args{ARGSRef}->{'UpdateContent'} ) { - - if ( - $args{ARGSRef}->{'UpdateSubject'} eq $args{'TicketObj'}->Subject() ) - { - $args{ARGSRef}->{'UpdateSubject'} = undef; - } - - my $Message = MakeMIMEEntity( - Subject => $args{ARGSRef}->{'UpdateSubject'}, - Body => $args{ARGSRef}->{'UpdateContent'}, - AttachmentFieldName => 'UpdateAttachment' - ); - - ## Check whether this was a refresh or not. - - # Match Correspondence or Comments. - my $trans_flag = -2; - my $trans_type = undef; - my $orig_trans = $args{ARGSRef}->{'UpdateType'}; - if ( $orig_trans =~ /^(private|public)$/ ) { - $trans_type = "Comment"; - }elsif ( $orig_trans eq 'response' ) { - $trans_type = "Correspond"; - } - - # Do we have a transaction that we need to update on? session - if( defined( $trans_type ) ){ - $trans_flag = 0; - - # Prepare a checksum. - # See perldoc -f unpack for example of this. - my $this_checksum = unpack("%32C*", $Message->body_as_string ) % 65535; - - # The above *could* generate duplicate checksums. Crosscheck with - # the length. - my $this_length = length( $Message->body_as_string ); - - # Don't forget the ticket id. - my $this_id = $args{TicketObj}->id; - - # Check whether the previous transaction in the - # ticket is the same as the current transaction. - if( defined( $session{'prev_trans_type'} ) && defined( $session{'prev_trans_chksum'} ) && defined( $session{'prev_trans_length'} ) && defined( $session{'prev_trans_tickid'} ) ){ - if( $session{'prev_trans_type'} eq $orig_trans && $session{'prev_trans_chksum'} == $this_checksum && $session{'prev_trans_length'} == $this_length && $session{'prev_trans_tickid'} == $this_id ){ - # Its the same as the previous transaction for this user. - $trans_flag = -1; - } - } - - # Store them for next time. - $session{'prev_trans_type'} = $orig_trans; - $session{'prev_trans_chksum'} = $this_checksum; - $session{'prev_trans_length'} = $this_length; - $session{'prev_trans_tickid'} = $this_id; - - if( $trans_flag == -1 ){ - push ( @{ $args{'Actions'} }, -"This appears to be a duplicate of your previous update (please do not refresh this page)" ); - } - - - if ( $trans_type eq 'Comment' && $trans_flag >= 0 ) { - my ( $Transaction, $Description ) = $args{TicketObj}->Comment( - CcMessageTo => $args{ARGSRef}->{'UpdateCc'}, - BccMessageTo => $args{ARGSRef}->{'UpdateBcc'}, - MIMEObj => $Message, - TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'} - ); - push ( @{ $args{Actions} }, $Description ); - } - elsif ( $trans_type eq 'Correspond' && $trans_flag >= 0 ) { - my ( $Transaction, $Description ) = $args{TicketObj}->Correspond( - CcMessageTo => $args{ARGSRef}->{'UpdateCc'}, - BccMessageTo => $args{ARGSRef}->{'UpdateBcc'}, - MIMEObj => $Message, - TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'} - ); - push ( @{ $args{Actions} }, $Description ); - } - } - else { - push ( @{ $args{'Actions'} }, - "Update type was neither correspondence nor comment. Update not recorded" - ); - } - } -} - -# }}} - -# {{{ sub MakeMIMEEntity - -=head2 MakeMIMEEntity PARAMHASH - -Takes a paramhash Subject, Body and AttachmentFieldName. +Takes paramhash with fields ARGSRef, TicketObj and SkipSignatureOnly. - Returns a MIME::Entity. +Don't write message if it only contains current user's signature and +SkipSignatureOnly argument is true. Function anyway adds attachments +and updates time worked field even if skips message. The default value +is true. =cut -sub MakeMIMEEntity { +sub ProcessUpdateMessage { - #TODO document what else this takes. my %args = ( - Subject => undef, - From => undef, - Cc => undef, - Body => undef, - AttachmentFieldName => undef, + ARGSRef => undef, + TicketObj => undef, + SkipSignatureOnly => 1, @_ ); - #Make the update content have no 'weird' newlines in it + if ( $args{ARGSRef}->{'UpdateAttachments'} + && !keys %{ $args{ARGSRef}->{'UpdateAttachments'} } ) + { + delete $args{ARGSRef}->{'UpdateAttachments'}; + } - $args{'Body'} =~ s/\r\n/\n/gs; - my $Message = MIME::Entity->build( - Subject => $args{'Subject'} || "", - From => $args{'From'}, - Cc => $args{'Cc'}, - Data => [ $args{'Body'} ] + # 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 $cgi_object = CGIObject(); - if ( $cgi_object->param( $args{'AttachmentFieldName'} ) ) { - - my $cgi_filehandle = - $cgi_object->upload( $args{'AttachmentFieldName'} ); - - use File::Temp qw(tempfile tempdir); - - #foreach my $filehandle (@filenames) { - - # my ( $fh, $temp_file ) = tempfile(); - - #$binmode $fh; #thank you, windows - - # We're having trouble with tempfiles not getting created. Let's try it with - # a scalar instead - - my ( $buffer, @file ); - - while ( my $bytesread = read( $cgi_filehandle, $buffer, 4096 ) ) { - push ( @file, $buffer ); + # If, after stripping the signature, we have no message, move the + # UpdateTimeWorked into adjusted TimeWorked, so that a later + # ProcessBasics can deal -- then bail out. + if ( not $args{ARGSRef}->{'UpdateAttachments'} + and not length $args{ARGSRef}->{'UpdateContent'} ) + { + if ( $args{ARGSRef}->{'UpdateTimeWorked'} ) { + $args{ARGSRef}->{TimeWorked} = $args{TicketObj}->TimeWorked + delete $args{ARGSRef}->{'UpdateTimeWorked'}; } - - $RT::Logger->debug($file); - my $filename = "$cgi_filehandle"; - $filename =~ s#^(.*)/##; - $filename =~ s#^(.*)\\##; - my $uploadinfo = $cgi_object->uploadInfo($cgi_filehandle); - $Message->attach( - Data => \@file, - - #Path => $temp_file, - Filename => $filename, - Type => $uploadinfo->{'Content-Type'} - ); - - #close($fh); - #unlink($temp_file); - - # } + return; } - $Message->make_singlepart(); - return ($Message); - -} - -# }}} -# {{{ sub ProcessSearchQuery - -=head2 ProcessSearchQuery + if ( $args{ARGSRef}->{'UpdateSubject'} eq $args{'TicketObj'}->Subject ) { + $args{ARGSRef}->{'UpdateSubject'} = undef; + } - Takes a form such as the one filled out in webrt/Search/Elements/PickRestriction and turns it into something that RT::Tickets can understand. + my $Message = MakeMIMEEntity( + Subject => $args{ARGSRef}->{'UpdateSubject'}, + Body => $args{ARGSRef}->{'UpdateContent'}, + Type => $args{ARGSRef}->{'UpdateContentType'}, + ); -TODO Doc exactly what comes in the paramhash + $Message->head->add( '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 + ); + } -=cut + if ( $args{ARGSRef}->{'UpdateAttachments'} ) { + $Message->make_multipart; + $Message->add_part($_) foreach values %{ $args{ARGSRef}->{'UpdateAttachments'} }; + } -sub ProcessSearchQuery { - my %args = @_; + 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'} ) ); + } - ## TODO: The only parameter here is %ARGS. Maybe it would be - ## cleaner to load this parameter as $ARGS, and use $ARGS->{...} - ## instead of $args{ARGS}->{...} ? :) + my $bcc = $args{ARGSRef}->{'UpdateBcc'}; + my $cc = $args{ARGSRef}->{'UpdateCc'}; - #Searches are sticky. - if ( defined $session{'tickets'} ) { + my %message_args = ( + CcMessageTo => $cc, + BccMessageTo => $bcc, + Sign => $args{ARGSRef}->{'Sign'}, + Encrypt => $args{ARGSRef}->{'Encrypt'}, + MIMEObj => $Message, + TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'} + ); - # Reset the old search - $session{'tickets'}->GotoFirstItem; + my @temp_squelch; + foreach my $type (qw(Cc AdminCc)) { + if (grep $_ eq $type || $_ eq ( $type . 's' ), @{ $args{ARGSRef}->{'SkipNotification'} || [] }) { + push @temp_squelch, map $_->address, Email::Address->parse( $message_args{$type} ); + push @temp_squelch, $args{TicketObj}->$type->MemberEmailAddresses; + push @temp_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses; + } } - else { - - # Init a new search - $session{'tickets'} = RT::Tickets->new( $session{'CurrentUser'} ); + if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) { + push @temp_squelch, map $_->address, Email::Address->parse( $message_args{Requestor} ); + push @temp_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses; } - #Import a bookmarked search if we have one - if ( defined $args{ARGS}->{'Bookmark'} ) { - $session{'tickets'}->ThawLimits( $args{ARGS}->{'Bookmark'} ); + if (@temp_squelch) { + require RT::Action::SendEmail; + RT::Action::SendEmail->SquelchMailTo( RT::Action::SendEmail->SquelchMailTo, @temp_squelch ); } - # {{{ Goto next/prev page - if ( $args{ARGS}->{'GotoPage'} eq 'Next' ) { - $session{'tickets'}->NextPage; - } - elsif ( $args{ARGS}->{'GotoPage'} eq 'Prev' ) { - $session{'tickets'}->PrevPage; + 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; + } + } } - # }}} + 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; +} - # {{{ Deal with limiting the search +# }}} - if ( $args{ARGS}->{'RefreshSearchInterval'} ) { - $session{'tickets_refresh_interval'} = - $args{ARGS}->{'RefreshSearchInterval'}; - } +# {{{ sub MakeMIMEEntity - if ( $args{ARGS}->{'TicketsSortBy'} ) { - $session{'tickets_sort_by'} = $args{ARGS}->{'TicketsSortBy'}; - $session{'tickets_sort_order'} = $args{ARGS}->{'TicketsSortOrder'}; - $session{'tickets'}->OrderBy( - FIELD => $args{ARGS}->{'TicketsSortBy'}, - ORDER => $args{ARGS}->{'TicketsSortOrder'} - ); - } +=head2 MakeMIMEEntity PARAMHASH - # }}} +Takes a paramhash Subject, Body and AttachmentFieldName. - # {{{ Set the query limit - if ( defined $args{ARGS}->{'RowsPerPage'} ) { - $RT::Logger->debug( - "limiting to " . $args{ARGS}->{'RowsPerPage'} . " rows" ); +Also takes Form, Cc and Type as optional paramhash keys. - $session{'tickets_rows_per_page'} = $args{ARGS}->{'RowsPerPage'}; - $session{'tickets'}->RowsPerPage( $args{ARGS}->{'RowsPerPage'} ); - } + Returns a MIME::Entity. - # }}} - # {{{ Limit priority - if ( $args{ARGS}->{'ValueOfPriority'} ne '' ) { - $session{'tickets'}->LimitPriority( - VALUE => $args{ARGS}->{'ValueOfPriority'}, - OPERATOR => $args{ARGS}->{'PriorityOp'} - ); - } +=cut - # }}} - # {{{ Limit owner - if ( $args{ARGS}->{'ValueOfOwner'} ne '' ) { - $session{'tickets'}->LimitOwner( - VALUE => $args{ARGS}->{'ValueOfOwner'}, - OPERATOR => $args{ARGS}->{'OwnerOp'} - ); - } +sub MakeMIMEEntity { - # }}} - # {{{ Limit requestor email + #TODO document what else this takes. + my %args = ( + Subject => undef, + From => undef, + Cc => undef, + Body => undef, + AttachmentFieldName => undef, + Type => undef, + @_, + ); + my $Message = MIME::Entity->build( + Type => 'multipart/mixed', + map { $_ => Encode::encode_utf8( $args{ $_} ) } + grep defined $args{$_}, qw(Subject From Cc) + ); - if ( $args{ARGS}->{'ValueOfRequestor'} ne '' ) { - my $alias = $session{'tickets'}->LimitRequestor( - VALUE => $args{ARGS}->{'ValueOfRequestor'}, - OPERATOR => $args{ARGS}->{'RequestorOp'}, - ); + if ( defined $args{'Body'} && length $args{'Body'} ) { - } + # Make the update content have no 'weird' newlines in it + $args{'Body'} =~ s/\r\n/\n/gs; - # }}} - # {{{ Limit Queue - if ( $args{ARGS}->{'ValueOfQueue'} ne '' ) { - $session{'tickets'}->LimitQueue( - VALUE => $args{ARGS}->{'ValueOfQueue'}, - OPERATOR => $args{ARGS}->{'QueueOp'} + $Message->attach( + Type => $args{'Type'} || 'text/plain', + Charset => 'UTF-8', + Data => $args{'Body'}, ); } - # }}} - # {{{ Limit Status - if ( $args{ARGS}->{'ValueOfStatus'} ne '' ) { - if ( ref( $args{ARGS}->{'ValueOfStatus'} ) ) { - foreach my $value ( @{ $args{ARGS}->{'ValueOfStatus'} } ) { - $session{'tickets'}->LimitStatus( - VALUE => $value, - OPERATOR => $args{ARGS}->{'StatusOp'}, - ); - } - } - else { - $session{'tickets'}->LimitStatus( - VALUE => $args{ARGS}->{'ValueOfStatus'}, - OPERATOR => $args{ARGS}->{'StatusOp'}, - ); - } - - } - - # }}} - # {{{ Limit Subject - if ( $args{ARGS}->{'ValueOfSubject'} ne '' ) { - $session{'tickets'}->LimitSubject( - VALUE => $args{ARGS}->{'ValueOfSubject'}, - OPERATOR => $args{ARGS}->{'SubjectOp'}, - ); - } + if ( $args{'AttachmentFieldName'} ) { - # }}} - # {{{ Limit Dates - if ( $args{ARGS}->{'ValueOfDate'} ne '' ) { + my $cgi_object = $m->cgi_object; - my $date = ParseDateToISO( $args{ARGS}->{'ValueOfDate'} ); - $args{ARGS}->{'DateType'} =~ s/_Date$//; + if ( my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} ) ) { - $session{'tickets'}->LimitDate( - FIELD => $args{ARGS}->{'DateType'}, - VALUE => $date, - OPERATOR => $args{ARGS}->{'DateOp'}, - ); - } + my ( @content, $buffer ); + while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) { + push @content, $buffer; + } - # }}} - # {{{ Limit Content - if ( $args{ARGS}->{'ValueOfContent'} ne '' ) { - $session{'tickets'}->LimitContent( - VALUE => $args{ARGS}->{'ValueOfContent'}, - OPERATOR => $args{ARGS}->{'ContentOp'}, - ); - } + my $uploadinfo = $cgi_object->uploadInfo($filehandle); - # }}} - # {{{ Limit KeywordSelects + # Prefer the cached name first over CGI.pm stringification. + my $filename = $RT::Mason::CGI::Filename; + $filename = "$filehandle" unless defined $filename; + $filename = Encode::encode_utf8( $filename ); + $filename =~ s{^.*[\\/]}{}; - foreach my $KeywordSelectId ( - map { /^KeywordSelect(\d+)$/; $1 } - grep { /^KeywordSelect(\d+)$/; } keys %{ $args{ARGS} } - ) - { - my $form = $args{ARGS}->{"KeywordSelect$KeywordSelectId"}; - my $oper = $args{ARGS}->{"KeywordSelectOp$KeywordSelectId"}; - foreach my $KeywordId ( ref($form) ? @{$form} : ($form) ) { - next unless ($KeywordId); - my $quote = 1; - if ( $KeywordId =~ /^null$/i ) { - - #Don't quote the string 'null' - $quote = 0; - - # Convert the operator to something apropriate for nulls - $oper = 'IS' if ( $oper eq '=' ); - $oper = 'IS NOT' if ( $oper eq '!=' ); - } - $session{'tickets'}->LimitKeyword( - KEYWORDSELECT => $KeywordSelectId, - OPERATOR => $oper, - QUOTEVALUE => $quote, - KEYWORD => $KeywordId + $Message->attach( + Type => $uploadinfo->{'Content-Type'}, + Filename => $filename, + Data => \@content, ); + if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) { + $Message->head->set( 'Subject' => $filename ); + } } } - # }}} + $Message->make_singlepart; + + RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8 + + return ($Message); } @@ -654,7 +1330,7 @@ Returns an ISO date and time in GMT sub ParseDateToISO { my $date = shift; - my $date_obj = new RT::Date($CurrentUser); + my $date_obj = RT::Date->new( $session{'CurrentUser'} ); $date_obj->Set( Format => 'unknown', Value => $date @@ -664,186 +1340,54 @@ sub ParseDateToISO { # }}} -# {{{ sub Config -# TODO: This might eventually read the cookies, user configuration -# information from the DB, queue configuration information from the -# DB, etc. - -sub Config { - my $args = shift; - my $key = shift; - return $args->{$key} || $RT::WebOptions{$key}; -} - -# }}} - # {{{ sub ProcessACLChanges sub ProcessACLChanges { - my $ACLref = shift; my $ARGSref = shift; - my @CheckACL = @$ACLref; - my %ARGS = %$ARGSref; - - my ( $ACL, @results ); - - # {{{ Add rights - foreach $ACL (@CheckACL) { - my ($Principal); - - next unless ($ACL); - - # Parse out what we're really talking about. - if ( $ACL =~ /^(.*?)-(\d+)-(.*?)-(\d+)/ ) { - my $PrincipalType = $1; - my $PrincipalId = $2; - my $Scope = $3; - my $AppliesTo = $4; - - # {{{ Create an object called Principal - # so we can do rights operations - - if ( $PrincipalType eq 'User' ) { - $Principal = new RT::User( $session{'CurrentUser'} ); - } - elsif ( $PrincipalType eq 'Group' ) { - $Principal = new RT::Group( $session{'CurrentUser'} ); - } - else { - Abort("$PrincipalType unknown principal type"); - } - - $Principal->Load($PrincipalId) - || Abort("$PrincipalType $PrincipalId couldn't be loaded"); + #XXX: why don't we get ARGSref like in other Process* subs? - # }}} + my @results; - # {{{ load up an RT::ACL object with the same current vals of this ACL + foreach my $arg ( keys %$ARGSref ) { + next unless ( $arg =~ /^(GrantRight|RevokeRight)-(\d+)-(.+?)-(\d+)$/ ); - my $CurrentACL = new RT::ACL( $session{'CurrentUser'} ); - if ( $Scope eq 'Queue' ) { - $CurrentACL->LimitToQueue($AppliesTo); - } - elsif ( $Scope eq 'System' ) { - $CurrentACL->LimitToSystem(); - } + my ( $method, $principal_id, $object_type, $object_id ) = ( $1, $2, $3, $4 ); - $CurrentACL->LimitPrincipalToType($PrincipalType); - $CurrentACL->LimitPrincipalToId($PrincipalId); - - # }}} - - # {{{ Get the values of the select we're working with - # into an array. it will contain all the new rights that have - # been granted - #Hack to turn the ACL returned into an array - my @rights = - ref( $ARGS{"GrantACE-$ACL"} ) eq 'ARRAY' - ? @{ $ARGS{"GrantACE-$ACL"} } - : ( $ARGS{"GrantACE-$ACL"} ); - - # }}} - - # {{{ Add any rights we need. - - foreach my $right (@rights) { - next unless ($right); - - #if the right that's been selected wasn't there before, add it. - unless ( - $CurrentACL->HasEntry( - RightScope => "$Scope", - RightName => "$right", - RightAppliesTo => "$AppliesTo", - PrincipalType => $PrincipalType, - PrincipalId => $Principal->Id - ) - ) - { - - #Add new entry to list of rights. - if ( $Scope eq 'Queue' ) { - my $Queue = new RT::Queue( $session{'CurrentUser'} ); - $Queue->Load($AppliesTo); - unless ( $Queue->id ) { - Abort("Couldn't find a queue called $AppliesTo"); - } - - my ( $val, $msg ) = $Principal->GrantQueueRight( - RightAppliesTo => $Queue->id, - RightName => "$right" - ); - - if ($val) { - push ( @results, - "Granted right $right to " - . $Principal->Name - . " for queue " - . $Queue->Name ); - } - else { - push ( @results, $msg ); - } - } - elsif ( $Scope eq 'System' ) { - my ( $val, $msg ) = $Principal->GrantSystemRight( - RightAppliesTo => $AppliesTo, - RightName => "$right" - ); - if ($val) { - push ( @results, "Granted system right '$right' to " - . $Principal->Name ); - } - else { - push ( @results, $msg ); - } - } - } + 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; } - } - # }}} Add rights - - # {{{ remove any rights that have been deleted - - my @RevokeACE = - ref( $ARGS{"RevokeACE"} ) eq 'ARRAY' - ? @{ $ARGS{"RevokeACE"} } - : ( $ARGS{"RevokeACE"} ); - - foreach my $aceid (@RevokeACE) { - - my $right = new RT::ACE( $session{'CurrentUser'} ); - $right->Load($aceid); - next unless ( $right->id ); - - my $phrase = "Revoked " - . $right->PrincipalType . " " - . $right->PrincipalObj->Name - . "'s right to " - . $right->RightName; - - if ( $right->RightScope eq 'System' ) { - $phrase .= ' across all queues.'; - } - else { - $phrase .= ' for the queue ' . $right->AppliesToObj->Name . '.'; - } - my ( $val, $msg ) = $right->Delete(); - if ($val) { - push ( @results, $phrase ); - } - else { - push ( @results, $msg ); + foreach my $right (@rights) { + my ( $val, $msg ) = $principal->$method( Object => $obj, Right => $right ); + push( @results, $msg ); } } - # }}} - return (@results); } @@ -861,29 +1405,75 @@ Returns an array of success/failure messages sub UpdateRecordObject { my %args = ( - ARGSRef => undef, - AttributesRef => undef, - Object => undef, + ARGSRef => undef, + AttributesRef => undef, + Object => undef, + AttributePrefix => undef, @_ ); - my (@results); + my $Object = $args{'Object'}; + my @results = $Object->Update( + AttributesRef => $args{'AttributesRef'}, + ARGSRef => $args{'ARGSRef'}, + AttributePrefix => $args{'AttributePrefix'}, + ); + + return (@results); +} - my $object = $args{'Object'}; - my $attributes = $args{'AttributesRef'}; - my $ARGSRef = $args{'ARGSRef'}; +# }}} - foreach $attribute (@$attributes) { - if ( ( defined $ARGSRef->{"$attribute"} ) - and ( $ARGSRef->{"$attribute"} ne $object->$attribute() ) ) - { - $ARGSRef->{"$attribute"} =~ s/\r\n/\n/gs; +# {{{ Sub ProcessCustomFieldUpdates + +sub ProcessCustomFieldUpdates { + my %args = ( + CustomFieldObj => undef, + ARGSRef => undef, + @_ + ); - my $method = "Set$attribute"; - my ( $code, $msg ) = $object->$method( $ARGSRef->{"$attribute"} ); - push @results, "$attribute: $msg"; + 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); } @@ -908,44 +1498,48 @@ sub ProcessTicketBasics { my $TicketObj = $args{'TicketObj'}; my $ARGSRef = $args{'ARGSRef'}; - # {{{ Set basic fields + # {{{ Set basic fields my @attribs = qw( - Subject - FinalPriority - Priority - TimeWorked - TimeLeft - Status - Queue + Subject + FinalPriority + Priority + TimeEstimated + TimeWorked + TimeLeft + Type + Status + Queue ); if ( $ARGSRef->{'Queue'} and ( $ARGSRef->{'Queue'} !~ /^(\d+)$/ ) ) { my $tempqueue = RT::Queue->new($RT::SystemUser); $tempqueue->Load( $ARGSRef->{'Queue'} ); if ( $tempqueue->id ) { - $ARGSRef->{'Queue'} = $tempqueue->Id(); + $ARGSRef->{'Queue'} = $tempqueue->id; } } + # Status isn't a field that can be set to a null value. + # RT core complains if you try + delete $ARGSRef->{'Status'} unless $ARGSRef->{'Status'}; + my @results = UpdateRecordObject( AttributesRef => \@attribs, Object => $TicketObj, - ARGSRef => $ARGSRef + ARGSRef => $ARGSRef, ); # We special case owner changing, so we can use ForceOwnerChange - if ( $ARGSRef->{'Owner'} && ( $TicketObj->Owner ne $ARGSRef->{'Owner'} ) ) { + if ( $ARGSRef->{'Owner'} && ( $TicketObj->Owner != $ARGSRef->{'Owner'} ) ) { my ($ChownType); if ( $ARGSRef->{'ForceOwnerChange'} ) { $ChownType = "Force"; - } - else { + } else { $ChownType = "Give"; } - my ( $val, $msg ) = - $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType ); - push ( @results, "$msg" ); + my ( $val, $msg ) = $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType ); + push( @results, $msg ); } # }}} @@ -955,6 +1549,217 @@ sub ProcessTicketBasics { # }}} +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}; + } + } + + 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->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'; + + # 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; + } + + $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; +} + # {{{ sub ProcessTicketWatchers =head2 ProcessTicketWatchers ( TicketObj => $Ticket, ARGSRef => \%ARGS ); @@ -974,25 +1779,31 @@ sub ProcessTicketWatchers { my $Ticket = $args{'TicketObj'}; my $ARGSRef = $args{'ARGSRef'}; - # {{{ Munge watchers + # Munge watchers foreach my $key ( keys %$ARGSRef ) { # Delete deletable watchers - if ( ( $key =~ /^DelWatcher(\d*)$/ ) and ( $ARGSRef->{$key} ) ) { - my ( $code, $msg ) = $Ticket->DeleteWatcher($1); + 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( $ARGSRef->{$key}, $1 ); + 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*)$/ ) ) + # 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 :/ @@ -1013,19 +1824,21 @@ sub ProcessTicketWatchers { } # Add new watchers by owner - elsif ( ( $ARGSRef->{$key} =~ /^(AdminCc|Cc|Requestor)$/ ) - and ( $key =~ /^WatcherTypeUser(\d*)$/ ) ) - { - - #They're in this order because otherwise $1 gets clobbered :/ - my ( $code, $msg ) = - $Ticket->AddWatcher( Type => $ARGSRef->{$key}, Owner => $1 ); - push @results, $msg; + 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); } @@ -1053,33 +1866,33 @@ sub ProcessTicketDates { # {{{ Set date fields my @date_fields = qw( - Told - Resolved - Starts - Started - Due + Told + Resolved + Starts + Started + Due ); #Run through each field in this list. update the value if apropriate - foreach $field (@date_fields) { + foreach my $field (@date_fields) { + next unless exists $ARGSRef->{ $field . '_Date' }; + next if $ARGSRef->{ $field . '_Date' } eq ''; + my ( $code, $msg ); my $DateObj = RT::Date->new( $session{'CurrentUser'} ); + $DateObj->Set( + Format => 'unknown', + Value => $ARGSRef->{ $field . '_Date' } + ); - #If it's something other than just whitespace - if ( $ARGSRef->{ $field . '_Date' } ne '' ) { - $DateObj->Set( - Format => 'unknown', - Value => $ARGSRef->{ $field . '_Date' } - ); - my $obj = $field . "Obj"; - if ( ( defined $DateObj->Unix ) - and ( $DateObj->Unix ne $Ticket->$obj()->Unix() ) ) - { - my $method = "Set$field"; - my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO ); - push @results, "$msg"; - } + my $obj = $field . "Obj"; + if ( ( defined $DateObj->Unix ) + and ( $DateObj->Unix != $Ticket->$obj()->Unix() ) ) + { + my $method = "Set$field"; + my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO ); + push @results, "$msg"; } } @@ -1107,6 +1920,30 @@ sub ProcessTicketLinks { 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. @@ -1116,9 +1953,7 @@ sub ProcessTicketLinks { my $type = $2; my $target = $3; - push @results, - "Trying to delete: Base: $base Target: $target Type $type"; - my ( $val, $msg ) = $Ticket->DeleteLink( + my ( $val, $msg ) = $Record->DeleteLink( Base => $base, Type => $type, Target => $target @@ -1133,155 +1968,138 @@ sub ProcessTicketLinks { my @linktypes = qw( DependsOn MemberOf RefersTo ); foreach my $linktype (@linktypes) { - - for my $luri ( split ( / /, $ARGSRef->{ $Ticket->Id . "-$linktype" } ) ) - { - $luri =~ s/\s*$//; # Strip trailing whitespace - my ( $val, $msg ) = $Ticket->AddLink( - Target => $luri, - Type => $linktype - ); - push @results, $msg; + 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 + ); - for my $luri ( split ( / /, $ARGSRef->{ "$linktype-" . $Ticket->Id } ) ) - { - my ( $val, $msg ) = $Ticket->AddLink( - Base => $luri, - Type => $linktype - ); - - push @results, $msg; + push @results, $msg; + } } } - #Merge if we need to - if ( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ) { - my ( $val, $msg ) = - $Ticket->MergeInto( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ); - push @results, $msg; - } - return (@results); } -# }}} - -# {{{ sub ProcessTicketObjectKeywords +=head2 _UploadedFile ( $arg ); -=head2 ProcessTicketObjectKeywords ( TicketObj => $Ticket, ARGSRef => \%ARGS ); +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 an array of results messages. +Returns C if no files were uploaded in the C<$arg> field. =cut -sub ProcessTicketObjectKeywords { - my %args = ( - TicketObj => undef, - ARGSRef => undef, - @_ - ); - - my $TicketObj = $args{'TicketObj'}; - my $ARGSRef = $args{'ARGSRef'}; - - my (@results); - - # {{{ set ObjectKeywords. - - my $KeywordSelects = $TicketObj->QueueObj->KeywordSelects; - - # iterate through all the keyword selects for this queue - while ( my $KeywordSelect = $KeywordSelects->Next ) { - - # {{{ do some setup - - # if we have KeywordSelectMagic for this keywordselect: - next - unless - defined $ARGSRef->{ 'KeywordSelectMagic' . $KeywordSelect->id }; - - # Lets get a hash of the possible values to work with - my $value = $ARGSRef->{ 'KeywordSelect' . $KeywordSelect->id } || []; - - #lets get all those values in a hash. regardless of # of entries - #we'll use this for adding and deleting keywords from this object. - my %values = map { $_ => 1 } ref($value) ? @{$value} : ($value); +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'}, + }; +} - # Load up the ObjectKeywords for this KeywordSelect for this ticket - my $ObjectKeys = $TicketObj->KeywordsObj( $KeywordSelect->id ); +sub GetColumnMapEntry { + my %args = ( Map => {}, Name => '', Attribute => undef, @_ ); - # }}} - # {{{ add new keywords + # deal with the simplest thing first + if ( $args{'Map'}{ $args{'Name'} } ) { + return $args{'Map'}{ $args{'Name'} }{ $args{'Attribute'} }; + } - foreach my $key ( keys %values ) { + # complex things + elsif ( my ( $mainkey, $subkey ) = $args{'Name'} =~ /^(.*?)\.{(.+)}$/ ) { + return undef unless $args{'Map'}->{$mainkey}; + return $args{'Map'}{$mainkey}{ $args{'Attribute'} } + unless ref $args{'Map'}{$mainkey}{ $args{'Attribute'} } eq 'CODE'; - #unless the ticket has that keyword for that keyword select, - unless ( $ObjectKeys->HasEntry($key) ) { + return sub { $args{'Map'}{$mainkey}{ $args{'Attribute'} }->( @_, $subkey ) }; + } + return undef; +} - #Add the keyword - my ( $result, $msg ) = $TicketObj->AddKeyword( - Keyword => $key, - KeywordSelect => $KeywordSelect->id - ); - push ( @results, $msg ); - } +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; } + } - # }}} - # {{{ Delete unused keywords + return $m->interp->apply_escapes( $value, 'h' ) if $args{'Escape'}; + return $value; +} - #redo this search, so we don't ask it to delete things that are already gone - # such as when a single keyword select gets its value changed. - $ObjectKeys = $TicketObj->KeywordsObj( $KeywordSelect->id ); +=head2 _load_container_object ( $type, $id ); - while ( my $TicketKey = $ObjectKeys->Next ) { +Instantiate container object for saving searches. - # if the hash defined above doesn\'t contain the keyword mentioned, - unless ( $values{ $TicketKey->Keyword } ) { +=cut - #I'd really love to just call $keyword->Delete, but then - # we wouldn't get a transaction recorded - my ( $result, $msg ) = $TicketObj->DeleteKeyword( - Keyword => $TicketKey->Keyword, - KeywordSelect => $KeywordSelect->id - ); - push ( @results, $msg ); - } - } +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 ); - #Iterate through the keyword selects for BulkManipulator style access - while ( my $KeywordSelect = $KeywordSelects->Next ) { - if ( $ARGSRef->{ "AddToKeywordSelect" . $KeywordSelect->Id } ) { +Given a serialization string for saved search, and returns the +container object and the search id. - #Add the keyword - my ( $result, $msg ) = $TicketObj->AddKeyword( - Keyword => - $ARGSRef->{ "AddToKeywordSelect" . $KeywordSelect->Id }, - KeywordSelect => $KeywordSelect->id - ); - push ( @results, $msg ); - } - if ( $ARGSRef->{ "DeleteFromKeywordSelect" . $KeywordSelect->Id } ) { +=cut - #Delete the keyword - my ( $result, $msg ) = $TicketObj->DeleteKeyword( - Keyword => - $ARGSRef->{ "DeleteFromKeywordSelect" . $KeywordSelect->Id }, - KeywordSelect => $KeywordSelect->id - ); - push ( @results, $msg ); - } +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 (@results); + return ( _load_container_object( $obj_type, $obj_id ), $search_id ); } -# }}} +eval "require RT::Interface::Web_Vendor"; +die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Web_Vendor.pm} ); +eval "require RT::Interface::Web_Local"; +die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Web_Local.pm} ); 1;