1 # BEGIN BPS TAGGED BLOCK {{{
5 # This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
6 # <sales@bestpractical.com>
8 # (Except where explicitly superseded by other copyright notices)
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
21 # General Public License for more details.
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
30 # CONTRIBUTION SUBMISSION POLICY:
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
47 # END BPS TAGGED BLOCK }}}
49 ## Portions Copyright 2000 Tobias Brox <tobix@fsck.com>
51 ## This is a library of static subs to be used by the Mason web
64 package RT::Interface::Web;
66 use RT::SavedSearches;
68 use RT::Interface::Web::Menu;
69 use RT::Interface::Web::Session;
72 use List::MoreUtils qw();
75 =head2 SquishedCSS $style
81 my $style = shift or die "need name";
82 return $SQUISHED_CSS{$style} if $SQUISHED_CSS{$style};
83 require RT::Squish::CSS;
84 my $css = RT::Squish::CSS->new( Style => $style );
85 $SQUISHED_CSS{ $css->Style } = $css;
95 return $SQUISHED_JS if $SQUISHED_JS;
97 require RT::Squish::JS;
98 my $js = RT::Squish::JS->new();
111 jquery-ui-1.10.0.custom.min.js
112 jquery-ui-timepicker-addon.js
113 jquery-ui-patch-datepicker.js
115 jquery.modal-defaults.js
121 jquery.event.hover-1.0.js
124 jquery.supposition.js
128 event-registration.js
130 /, RT->Config->Get('JSFiles');
135 Removes the cached CSS and JS entries, forcing them to be regenerated
145 =head2 EscapeHTML SCALARREF
147 does a css-busting but minimalist escaping of whatever html you're passing in.
153 return unless defined $$ref;
155 $$ref =~ s/&/&/g;
158 $$ref =~ s/\(/(/g;
159 $$ref =~ s/\)/)/g;
160 $$ref =~ s/"/"/g;
161 $$ref =~ s/'/'/g;
168 Instead => "EscapeHTML",
174 =head2 EscapeURI SCALARREF
176 Escapes URI component according to RFC2396
182 return unless defined $$ref;
185 $$ref =~ s/([^a-zA-Z0-9_.!~*'()-])/uc sprintf("%%%02X", ord($1))/eg;
188 =head2 EncodeJSON SCALAR
190 Encodes the SCALAR to JSON and returns a JSON Unicode (B<not> UTF-8) string.
191 SCALAR may be a simple value or a reference.
196 my $s = JSON::to_json(shift, { allow_nonref => 1 });
201 sub _encode_surrogates {
202 my $uni = $_[0] - 0x10000;
203 return ($uni / 0x400 + 0xD800, $uni % 0x400 + 0xDC00);
208 return unless defined $$ref;
210 $$ref = "'" . join('',
212 chr($_) =~ /[a-zA-Z0-9]/ ? chr($_) :
213 $_ <= 255 ? sprintf("\\x%02X", $_) :
214 $_ <= 65535 ? sprintf("\\u%04X", $_) :
215 sprintf("\\u%X\\u%X", _encode_surrogates($_))
216 } unpack('U*', $$ref))
220 =head2 WebCanonicalizeInfo();
222 Different web servers set different environmental varibles. This
223 function must return something suitable for REMOTE_USER. By default,
224 just downcase $ENV{'REMOTE_USER'}
228 sub WebCanonicalizeInfo {
229 return $ENV{'REMOTE_USER'} ? lc $ENV{'REMOTE_USER'} : $ENV{'REMOTE_USER'};
234 =head2 WebRemoteUserAutocreateInfo($user);
236 Returns a hash of user attributes, used when WebRemoteUserAutocreate is set.
240 sub WebRemoteUserAutocreateInfo {
245 # default to making Privileged users, even if they specify
246 # some other default Attributes
247 if ( !$RT::UserAutocreateDefaultsOnLogin
248 || ( ref($RT::UserAutocreateDefaultsOnLogin) && not exists $RT::UserAutocreateDefaultsOnLogin->{Privileged} ) )
250 $user_info{'Privileged'} = 1;
253 # Populate fields with information from Unix /etc/passwd
254 my ( $comments, $realname ) = ( getpwnam($user) )[ 5, 6 ];
255 $user_info{'Comments'} = $comments if defined $comments;
256 $user_info{'RealName'} = $realname if defined $realname;
258 # and return the wad of stuff
266 if (RT->Config->Get('DevelMode')) {
267 require Module::Refresh;
268 Module::Refresh->refresh;
271 $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8");
273 $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ];
275 # Roll back any dangling transactions from a previous failed connection
276 $RT::Handle->ForceRollback() if $RT::Handle and $RT::Handle->TransactionDepth;
278 MaybeEnableSQLStatementLog();
280 # avoid reentrancy, as suggested by masonbook
281 local *HTML::Mason::Commands::session unless $HTML::Mason::Commands::m->is_subrequest;
283 $HTML::Mason::Commands::m->autoflush( $HTML::Mason::Commands::m->request_comp->attr('AutoFlush') )
284 if ( $HTML::Mason::Commands::m->request_comp->attr_exists('AutoFlush') );
289 local $HTML::Mason::Commands::DECODED_ARGS = $ARGS;
290 PreprocessTimeUpdates($ARGS);
293 MaybeShowInstallModePage();
295 $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS );
298 if ( _UserLoggedIn() ) {
299 # make user info up to date
300 $HTML::Mason::Commands::session{'CurrentUser'}
301 ->Load( $HTML::Mason::Commands::session{'CurrentUser'}->id );
302 undef $HTML::Mason::Commands::session{'CurrentUser'}->{'LangHandle'};
305 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
308 # Process session-related callbacks before any auth attempts
309 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Session', CallbackPage => '/autohandler' );
311 MaybeRejectPrivateComponentRequest();
313 MaybeShowNoAuthPage($ARGS);
315 AttemptExternalAuth($ARGS) if RT->Config->Get('WebRemoteUserContinuous') or not _UserLoggedIn();
317 _ForceLogout() unless _UserLoggedIn();
319 # Process per-page authentication callbacks
320 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Auth', CallbackPage => '/autohandler' );
322 if ( $ARGS->{'NotMobile'} ) {
323 $HTML::Mason::Commands::session{'NotMobile'} = 1;
326 unless ( _UserLoggedIn() ) {
329 # Authenticate if the user is trying to login via user/pass query args
330 my ($authed, $msg) = AttemptPasswordAuthentication($ARGS);
333 my $m = $HTML::Mason::Commands::m;
335 # REST urls get a special 401 response
336 if ($m->request_comp->path =~ m{^/REST/\d+\.\d+/}) {
337 $HTML::Mason::Commands::r->content_type("text/plain; charset=utf-8");
338 $m->error_format("text");
339 $m->out("RT/$RT::VERSION 401 Credentials required\n");
340 $m->out("\n$msg\n") if $msg;
343 # Specially handle /index.html and /m/index.html so that we get a nicer URL
344 elsif ( $m->request_comp->path =~ m{^(/m)?/index\.html$} ) {
345 my $mobile = $1 ? 1 : 0;
346 my $next = SetNextPage($ARGS);
347 $m->comp('/NoAuth/Login.html',
354 TangentForLogin($ARGS, results => ($msg ? LoginError($msg) : undef));
359 MaybeShowInterstitialCSRFPage($ARGS);
361 # now it applies not only to home page, but any dashboard that can be used as a workspace
362 $HTML::Mason::Commands::session{'home_refresh_interval'} = $ARGS->{'HomeRefreshInterval'}
363 if ( $ARGS->{'HomeRefreshInterval'} );
365 # Process per-page global callbacks
366 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Default', CallbackPage => '/autohandler' );
368 ShowRequestedPage($ARGS);
369 LogRecordedSQLStatements(RequestData => {
370 Path => $HTML::Mason::Commands::m->request_path,
373 # Process per-page final cleanup callbacks
374 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Final', CallbackPage => '/autohandler' );
376 $HTML::Mason::Commands::m->comp( '/Elements/Footer', %$ARGS );
381 delete $HTML::Mason::Commands::session{'CurrentUser'};
385 if ( $HTML::Mason::Commands::session{CurrentUser} && $HTML::Mason::Commands::session{'CurrentUser'}->id ) {
393 =head2 LoginError ERROR
395 Pushes a login error into the Actions session store and returns the hash key.
401 my $key = Digest::MD5::md5_hex( rand(1024) );
402 push @{ $HTML::Mason::Commands::session{"Actions"}->{$key} ||= [] }, $new;
403 $HTML::Mason::Commands::session{'i'}++;
407 =head2 SetNextPage ARGSRef [PATH]
409 Intuits and stashes the next page in the sesssion hash. If PATH is
410 specified, uses that instead of the value of L<IntuitNextPage()>. Returns
417 my $next = $_[0] ? $_[0] : IntuitNextPage();
418 my $hash = Digest::MD5::md5_hex($next . $$ . rand(1024));
419 my $page = { url => $next };
421 # If an explicit URL was passed and we didn't IntuitNextPage, then
422 # IsPossibleCSRF below is almost certainly unrelated to the actual
423 # destination. Currently explicit next pages aren't used in RT, but the
425 if (not $_[0] and RT->Config->Get("RestrictReferrer")) {
426 # This isn't really CSRF, but the CSRF heuristics are useful for catching
427 # requests which may have unintended side-effects.
428 my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS);
431 "Marking original destination as having side-effects before redirecting for login.\n"
433 ."Reason: " . HTML::Mason::Commands::loc($msg, @loc)
435 $page->{'HasSideEffects'} = [$msg, @loc];
439 $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $page;
440 $HTML::Mason::Commands::session{'i'}++;
444 =head2 FetchNextPage HASHKEY
446 Returns the stashed next page hashref for the given hash.
451 my $hash = shift || "";
452 return $HTML::Mason::Commands::session{'NextPage'}->{$hash};
455 =head2 RemoveNextPage HASHKEY
457 Removes the stashed next page for the given hash and returns it.
462 my $hash = shift || "";
463 return delete $HTML::Mason::Commands::session{'NextPage'}->{$hash};
466 =head2 TangentForLogin ARGSRef [HASH]
468 Redirects to C</NoAuth/Login.html>, setting the value of L<IntuitNextPage> as
469 the next page. Takes a hashref of request %ARGS as the first parameter.
470 Optionally takes all other parameters as a hash which is dumped into query
475 sub TangentForLogin {
476 my $login = TangentForLoginURL(@_);
477 Redirect( RT->Config->Get('WebBaseURL') . $login );
480 =head2 TangentForLoginURL [HASH]
482 Returns a URL suitable for tangenting for login. Optionally takes a hash which
483 is dumped into query params.
487 sub TangentForLoginURL {
489 my $hash = SetNextPage($ARGS);
490 my %query = (@_, next => $hash);
493 if $HTML::Mason::Commands::m->request_comp->path =~ m{^/m(/|$)};
495 my $login = RT->Config->Get('WebPath') . '/NoAuth/Login.html?';
496 $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query);
500 =head2 TangentForLoginWithError ERROR
502 Localizes the passed error message, stashes it with L<LoginError> and then
503 calls L<TangentForLogin> with the appropriate results key.
507 sub TangentForLoginWithError {
509 my $key = LoginError(HTML::Mason::Commands::loc(@_));
510 TangentForLogin( $ARGS, results => $key );
513 =head2 IntuitNextPage
515 Attempt to figure out the path to which we should return the user after a
516 tangent. The current request URL is used, or failing that, the C<WebURL>
517 configuration variable.
524 # This includes any query parameters. Redirect will take care of making
525 # it an absolute URL.
526 if ($ENV{'REQUEST_URI'}) {
527 $req_uri = $ENV{'REQUEST_URI'};
529 # collapse multiple leading slashes so the first part doesn't look like
530 # a hostname of a schema-less URI
531 $req_uri =~ s{^/+}{/};
534 my $next = defined $req_uri ? $req_uri : RT->Config->Get('WebURL');
537 my $uri = URI->new($next);
539 # You get undef scheme with a relative uri like "/Search/Build.html"
540 unless (!defined($uri->scheme) || $uri->scheme eq 'http' || $uri->scheme eq 'https') {
541 $next = RT->Config->Get('WebURL');
544 # Make sure we're logging in to the same domain
545 # You can get an undef authority with a relative uri like "index.html"
546 my $uri_base_url = URI->new(RT->Config->Get('WebBaseURL'));
547 unless (!defined($uri->authority) || $uri->authority eq $uri_base_url->authority) {
548 $next = RT->Config->Get('WebURL');
554 =head2 MaybeShowInstallModePage
556 This function, called exclusively by RT's autohandler, dispatches
557 a request to RT's Installation workflow, only if Install Mode is enabled in the configuration file.
559 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
563 sub MaybeShowInstallModePage {
564 return unless RT->InstallMode;
566 my $m = $HTML::Mason::Commands::m;
567 if ( $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex') ) {
569 } elsif ( $m->request_comp->path !~ m{^(/+)Install/} ) {
570 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "Install/index.html" );
577 =head2 MaybeShowNoAuthPage \%ARGS
579 This function, called exclusively by RT's autohandler, dispatches
580 a request to the page a user requested (but only if it matches the "noauth" regex.
582 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
586 sub MaybeShowNoAuthPage {
589 my $m = $HTML::Mason::Commands::m;
591 return unless $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex');
593 # Don't show the login page to logged in users
594 Redirect(RT->Config->Get('WebURL'))
595 if $m->base_comp->path eq '/NoAuth/Login.html' and _UserLoggedIn();
597 # If it's a noauth file, don't ask for auth.
598 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
602 =head2 MaybeRejectPrivateComponentRequest
604 This function will reject calls to private components, like those under
605 C</Elements>. If the requested path is a private component then we will
606 abort with a C<403> error.
610 sub MaybeRejectPrivateComponentRequest {
611 my $m = $HTML::Mason::Commands::m;
612 my $path = $m->request_comp->path;
614 # We do not check for dhandler here, because requesting our dhandlers
615 # directly is okay. Mason will invoke the dhandler with a dhandler_arg of
621 _elements | # mobile UI
624 autohandler | # requesting this directly is suspicious
625 l (_unsafe)? ) # loc component
626 ( $ | / ) # trailing slash or end of path
635 $HTML::Mason::Commands::m->notes('menu', RT::Interface::Web::Menu->new());
636 $HTML::Mason::Commands::m->notes('page-menu', RT::Interface::Web::Menu->new());
637 $HTML::Mason::Commands::m->notes('page-widgets', RT::Interface::Web::Menu->new());
642 =head2 ShowRequestedPage \%ARGS
644 This function, called exclusively by RT's autohandler, dispatches
645 a request to the page a user requested (making sure that unpriviled users
646 can only see self-service pages.
650 sub ShowRequestedPage {
653 my $m = $HTML::Mason::Commands::m;
655 # Ensure that the cookie that we send is up-to-date, in case the
656 # session-id has been modified in any way
659 # precache all system level rights for the current user
660 $HTML::Mason::Commands::session{CurrentUser}->PrincipalObj->HasRights( Object => RT->System );
662 # If the user isn't privileged, they can only see SelfService
663 unless ( $HTML::Mason::Commands::session{'CurrentUser'}->Privileged ) {
665 # if the user is trying to access a ticket, redirect them
666 if ( $m->request_comp->path =~ m{^(/+)Ticket/Display.html} && $ARGS->{'id'} ) {
667 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/Display.html?id=" . $ARGS->{'id'} );
670 # otherwise, drop the user at the SelfService default page
671 elsif ( $m->base_comp->path !~ RT->Config->Get('SelfServiceRegex') ) {
672 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/" );
675 # if user is in SelfService dir let him do anything
677 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
680 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
685 sub AttemptExternalAuth {
688 return unless ( RT->Config->Get('WebRemoteUserAuth') );
690 my $user = $ARGS->{user};
691 my $m = $HTML::Mason::Commands::m;
693 my $logged_in_external_user = _UserLoggedIn() && $HTML::Mason::Commands::session{'WebExternallyAuthed'};
695 # If RT is configured for external auth, let's go through and get REMOTE_USER
697 # Do we actually have a REMOTE_USER or equivalent? We only check auth if
698 # 1) we have no logged in user, or 2) we have a user who is externally
699 # authed. If we have a logged in user who is internally authed, don't
700 # check remote user otherwise we may log them out.
701 if (RT::Interface::Web::WebCanonicalizeInfo()
702 and (not _UserLoggedIn() or $logged_in_external_user) )
704 $user = RT::Interface::Web::WebCanonicalizeInfo();
705 my $load_method = RT->Config->Get('WebRemoteUserGecos') ? 'LoadByGecos' : 'Load';
707 if ( $^O eq 'MSWin32' and RT->Config->Get('WebRemoteUserGecos') ) {
708 my $NodeName = Win32::NodeName();
709 $user =~ s/^\Q$NodeName\E\\//i;
712 my $next = RemoveNextPage($ARGS->{'next'});
713 $next = $next->{'url'} if ref $next;
714 InstantiateNewSession() unless _UserLoggedIn;
715 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
716 $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user);
718 if ( RT->Config->Get('WebRemoteUserAutocreate') and not _UserLoggedIn() ) {
720 # Create users on-the-fly
721 my $UserObj = RT::User->new(RT->SystemUser);
722 my ( $val, $msg ) = $UserObj->Create(
723 %{ ref RT->Config->Get('UserAutocreateDefaultsOnLogin') ? RT->Config->Get('UserAutocreateDefaultsOnLogin') : {} },
730 # now get user specific information, to better create our user.
731 my $new_user_info = RT::Interface::Web::WebRemoteUserAutocreateInfo($user);
733 # set the attributes that have been defined.
734 foreach my $attribute ( $UserObj->WritableAttributes, qw(Privileged Disabled) ) {
736 Attribute => $attribute,
738 UserInfo => $new_user_info,
739 CallbackName => 'NewUser',
740 CallbackPage => '/autohandler'
742 my $method = "Set$attribute";
743 $UserObj->$method( $new_user_info->{$attribute} ) if defined $new_user_info->{$attribute};
745 $HTML::Mason::Commands::session{'CurrentUser'}->Load($user);
747 RT->Logger->error("Couldn't auto-create user '$user' when attempting WebRemoteUser: $msg");
748 AbortExternalAuth( Error => "UserAutocreateDefaultsOnLogin" );
752 if ( _UserLoggedIn() ) {
753 $HTML::Mason::Commands::session{'WebExternallyAuthed'} = 1;
754 $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' );
755 # It is possible that we did a redirect to the login page,
756 # if the external auth allows lack of auth through with no
757 # REMOTE_USER set, instead of forcing a "permission
758 # denied" message. Honor the $next.
759 Redirect($next) if $next;
760 # Unlike AttemptPasswordAuthentication below, we do not
761 # force a redirect to / if $next is not set -- otherwise,
762 # straight-up external auth would always redirect to /
763 # when you first hit it.
765 # Couldn't auth with the REMOTE_USER provided because an RT
766 # user doesn't exist and we're configured not to create one.
767 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.");
769 Error => "NoInternalUser",
774 elsif ($logged_in_external_user) {
775 # The logged in external user was deauthed by the auth system and we
776 # should kick them out.
777 AbortExternalAuth( Error => "Deauthorized" );
779 elsif (not RT->Config->Get('WebFallbackToRTLogin')) {
780 # Abort if we don't want to fallback internally
781 AbortExternalAuth( Error => "NoRemoteUser" );
785 sub AbortExternalAuth {
787 my $error = $args{Error} ? "/Errors/WebRemoteUser/$args{Error}" : undef;
788 my $m = $HTML::Mason::Commands::m;
789 my $r = $HTML::Mason::Commands::r;
793 # Clear the decks, not that we should have partial content.
797 $m->comp($error, %args)
798 if $error and $m->comp_exists($error);
800 # Return a 403 Forbidden or we may fallback to a login page with no form
804 sub AttemptPasswordAuthentication {
806 return unless defined $ARGS->{user} && defined $ARGS->{pass};
808 my $user_obj = RT::CurrentUser->new();
809 $user_obj->Load( $ARGS->{user} );
811 my $m = $HTML::Mason::Commands::m;
813 unless ( $user_obj->id && $user_obj->IsPassword( $ARGS->{pass} ) ) {
814 $RT::Logger->error("FAILED LOGIN for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
815 $m->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' );
816 return (0, HTML::Mason::Commands::loc('Your username or password is incorrect'));
819 $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
821 # It's important to nab the next page from the session before we blow
823 my $next = RemoveNextPage($ARGS->{'next'});
824 $next = $next->{'url'} if ref $next;
826 InstantiateNewSession();
827 $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
829 $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' );
831 # Really the only time we don't want to redirect here is if we were
832 # passed user and pass as query params in the URL.
836 elsif ($ARGS->{'next'}) {
837 # Invalid hash, but still wants to go somewhere, take them to /
838 Redirect(RT->Config->Get('WebURL'));
841 return (1, HTML::Mason::Commands::loc('Logged in'));
845 =head2 LoadSessionFromCookie
847 Load or setup a session cookie for the current user.
851 sub _SessionCookieName {
852 my $cookiename = "RT_SID_" . RT->Config->Get('rtname');
853 $cookiename .= "." . $ENV{'SERVER_PORT'} if $ENV{'SERVER_PORT'};
857 sub LoadSessionFromCookie {
859 my %cookies = CGI::Cookie->fetch;
860 my $cookiename = _SessionCookieName();
861 my $SessionCookie = ( $cookies{$cookiename} ? $cookies{$cookiename}->value : undef );
862 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', $SessionCookie;
863 unless ( $SessionCookie && $HTML::Mason::Commands::session{'_session_id'} eq $SessionCookie ) {
864 InstantiateNewSession();
866 if ( int RT->Config->Get('AutoLogoff') ) {
867 my $now = int( time / 60 );
868 my $last_update = $HTML::Mason::Commands::session{'_session_last_update'} || 0;
870 if ( $last_update && ( $now - $last_update - RT->Config->Get('AutoLogoff') ) > 0 ) {
871 InstantiateNewSession();
874 # save session on each request when AutoLogoff is turned on
875 $HTML::Mason::Commands::session{'_session_last_update'} = $now if $now != $last_update;
879 sub InstantiateNewSession {
880 tied(%HTML::Mason::Commands::session)->delete if tied(%HTML::Mason::Commands::session);
881 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', undef;
885 sub SendSessionCookie {
886 my $cookie = CGI::Cookie->new(
887 -name => _SessionCookieName(),
888 -value => $HTML::Mason::Commands::session{_session_id},
889 -path => RT->Config->Get('WebPath'),
890 -secure => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 ),
891 -httponly => ( RT->Config->Get('WebHttpOnlyCookies') ? 1 : 0 ),
894 $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string;
897 =head2 GetWebURLFromRequest
899 People may use different web urls instead of C<$WebURL> in config.
900 Return the web url current user is using.
904 sub GetWebURLFromRequest {
906 my $uri = URI->new( RT->Config->Get('WebURL') );
908 if ( defined $ENV{HTTPS} and $ENV{'HTTPS'} eq 'on' ) {
909 $uri->scheme('https');
912 $uri->scheme('http');
915 # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST
916 $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} );
917 $uri->port( $ENV{'SERVER_PORT'} );
918 return "$uri"; # stringify to be consistent with WebURL in config
923 This routine ells the current user's browser to redirect to URL.
924 Additionally, it unties the user's currently active session, helping to avoid
925 A bug in Apache::Session 1.81 and earlier which clobbers sessions if we try to use
926 a cached DBI statement handle twice at the same time.
931 my $redir_to = shift;
932 untie $HTML::Mason::Commands::session;
933 my $uri = URI->new($redir_to);
934 my $server_uri = URI->new( RT->Config->Get('WebURL') );
936 # Make relative URIs absolute from the server host and scheme
937 $uri->scheme($server_uri->scheme) if not defined $uri->scheme;
938 if (not defined $uri->host) {
939 $uri->host($server_uri->host);
940 $uri->port($server_uri->port);
943 # If the user is coming in via a non-canonical
944 # hostname, don't redirect them to the canonical host,
945 # it will just upset them (and invalidate their credentials)
946 # don't do this if $RT::CanonicalizeRedirectURLs is true
947 if ( !RT->Config->Get('CanonicalizeRedirectURLs')
948 && $uri->host eq $server_uri->host
949 && $uri->port eq $server_uri->port )
951 my $env_uri = URI->new(GetWebURLFromRequest());
952 $uri->scheme($env_uri->scheme);
953 $uri->host($env_uri->host);
954 $uri->port($env_uri->port);
957 # not sure why, but on some systems without this call mason doesn't
958 # set status to 302, but 200 instead and people see blank pages
959 $HTML::Mason::Commands::r->status(302);
961 # Perlbal expects a status message, but Mason's default redirect status
962 # doesn't provide one. See also rt.cpan.org #36689.
963 $HTML::Mason::Commands::m->redirect( $uri->canonical, "302 Found" );
965 $HTML::Mason::Commands::m->abort;
968 =head2 CacheControlExpiresHeaders
970 set both Cache-Control and Expires http headers
974 sub CacheControlExpiresHeaders {
977 my $Visibility = 'private';
978 if ( ! defined $args{Time} ) {
980 } elsif ( $args{Time} eq 'no-cache' ) {
982 } elsif ( $args{Time} eq 'forever' ) {
983 $args{Time} = 30 * 24 * 60 * 60;
984 $Visibility = 'public';
987 my $CacheControl = $args{Time}
988 ? sprintf "max-age=%d, %s", $args{Time}, $Visibility
991 $HTML::Mason::Commands::r->headers_out->{'Cache-Control'} = $CacheControl;
993 my $expires = RT::Date->new(RT->SystemUser);
995 $expires->AddSeconds( $args{Time} ) if $args{Time};
997 $HTML::Mason::Commands::r->headers_out->{'Expires'} = $expires->RFC2616;
1000 =head2 StaticFileHeaders
1002 Send the browser a few headers to try to get it to (somewhat agressively)
1003 cache RT's static Javascript and CSS files.
1005 This routine could really use _accurate_ heuristics. (XXX TODO)
1009 sub StaticFileHeaders {
1010 my $date = RT::Date->new(RT->SystemUser);
1012 # remove any cookie headers -- if it is cached publicly, it
1013 # shouldn't include anyone's cookie!
1014 delete $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'};
1016 # Expire things in a month.
1017 CacheControlExpiresHeaders( Time => 'forever' );
1019 # if we set 'Last-Modified' then browser request a comp using 'If-Modified-Since'
1020 # request, but we don't handle it and generate full reply again
1021 # Last modified at server start time
1022 # $date->Set( Value => $^T );
1023 # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616;
1026 =head2 ComponentPathIsSafe PATH
1028 Takes C<PATH> and returns a boolean indicating that the user-specified partial
1029 component path is safe.
1031 Currently "safe" means that the path does not start with a dot (C<.>), does
1032 not contain a slash-dot C</.>, and does not contain any nulls.
1036 sub ComponentPathIsSafe {
1039 return $path !~ m{(?:^|/)\.} and $path !~ m{\0};
1044 Takes a C<< Path => path >> and returns a boolean indicating that
1045 the path is safely within RT's control or not. The path I<must> be
1048 This function does not consult the filesystem at all; it is merely
1049 a logical sanity checking of the path. This explicitly does not handle
1050 symlinks; if you have symlinks in RT's webroot pointing outside of it,
1051 then we assume you know what you are doing.
1058 my $path = $args{Path};
1060 # Get File::Spec to clean up extra /s, ./, etc
1061 my $cleaned_up = File::Spec->canonpath($path);
1063 if (!defined($cleaned_up)) {
1064 $RT::Logger->info("Rejecting path that canonpath doesn't understand: $path");
1068 # Forbid too many ..s. We can't just sum then check because
1069 # "../foo/bar/baz" should be illegal even though it has more
1070 # downdirs than updirs. So as soon as we get a negative score
1071 # (which means "breaking out" of the top level) we reject the path.
1073 my @components = split '/', $cleaned_up;
1075 for my $component (@components) {
1076 if ($component eq '..') {
1079 $RT::Logger->info("Rejecting unsafe path: $path");
1083 elsif ($component eq '.' || $component eq '') {
1084 # these two have no effect on $score
1094 =head2 SendStaticFile
1096 Takes a File => path and a Type => Content-type
1098 If Type isn't provided and File is an image, it will
1099 figure out a sane Content-type, otherwise it will
1100 send application/octet-stream
1102 Will set caching headers using StaticFileHeaders
1106 sub SendStaticFile {
1109 my $file = $args{File};
1110 my $type = $args{Type};
1111 my $relfile = $args{RelativeFile};
1113 if (defined($relfile) && !$self->PathIsSafe(Path => $relfile)) {
1114 $HTML::Mason::Commands::r->status(400);
1115 $HTML::Mason::Commands::m->abort;
1118 $self->StaticFileHeaders();
1121 if ( $file =~ /\.(gif|png|jpe?g)$/i ) {
1123 $type =~ s/jpg/jpeg/gi;
1125 $type ||= "application/octet-stream";
1127 $HTML::Mason::Commands::r->content_type($type);
1128 open( my $fh, '<', $file ) or die "couldn't open file: $!";
1132 $HTML::Mason::Commands::m->out($_) while (<$fh>);
1133 $HTML::Mason::Commands::m->flush_buffer;
1144 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'}) {
1155 my $content = $args{Content};
1156 return '' unless $content;
1158 # Make the content have no 'weird' newlines in it
1159 $content =~ s/\r+\n/\n/g;
1161 my $return_content = $content;
1163 my $html = $args{ContentType} && $args{ContentType} eq "text/html";
1164 my $sigonly = $args{StripSignature};
1166 # massage content to easily detect if there's any real content
1167 $content =~ s/\s+//g; # yes! remove all the spaces
1169 # remove html version of spaces and newlines
1170 $content =~ s! !!g;
1171 $content =~ s!<br/?>!!g;
1174 # Filter empty content when type is text/html
1175 return '' if $html && $content !~ /\S/;
1177 # If we aren't supposed to strip the sig, just bail now.
1178 return $return_content unless $sigonly;
1180 # Find the signature
1181 my $sig = $args{'CurrentUser'}->UserObj->Signature || '';
1184 # Check for plaintext sig
1185 return '' if not $html and $content =~ /^(--)?\Q$sig\E$/;
1187 # Check for html-formatted sig; we don't use EscapeHTML here
1188 # because we want to precisely match the escapting that FCKEditor
1190 $sig =~ s/&/&/g;
1193 $sig =~ s/"/"/g;
1194 $sig =~ s/'/'/g;
1195 return '' if $html and $content =~ m{^(?:<p>)?(--)?\Q$sig\E(?:</p>)?$}s;
1198 return $return_content;
1206 # if they've passed multiple values, they'll be an array. if they've
1207 # passed just one, a scalar whatever they are, mark them as utf8
1210 ? Encode::is_utf8($_)
1212 : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ )
1213 : ( $type eq 'ARRAY' )
1214 ? [ map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) }
1216 : ( $type eq 'HASH' )
1217 ? { map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) }
1223 sub PreprocessTimeUpdates {
1226 # Later in the code we use
1227 # $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %ARGS );
1228 # instead of $m->call_next to avoid problems with UTF8 keys in arguments.
1229 # The call_next method pass through original arguments and if you have
1230 # an argument with unicode key then in a next component you'll get two
1231 # records in the args hash: one with key without UTF8 flag and another
1232 # with the flag, which may result into errors. "{ base_comp => $m->request_comp }"
1233 # is copied from mason's source to get the same results as we get from
1234 # call_next method, this feature is not documented, so we just leave it
1235 # here to avoid possible side effects.
1237 # This code canonicalizes time inputs in hours into minutes
1238 foreach my $field ( keys %$ARGS ) {
1239 next unless $field =~ /^(.*)-TimeUnits$/i && $ARGS->{$1};
1241 $ARGS->{$local} =~ s{\b (?: (\d+) \s+ )? (\d+)/(\d+) \b}
1242 {($1 || 0) + $3 ? $2 / $3 : 0}xe;
1243 if ( $ARGS->{$field} && $ARGS->{$field} =~ /hours/i ) {
1244 $ARGS->{$local} *= 60;
1246 delete $ARGS->{$field};
1251 sub MaybeEnableSQLStatementLog {
1253 my $log_sql_statements = RT->Config->Get('StatementLog');
1255 if ($log_sql_statements) {
1256 $RT::Handle->ClearSQLStatementLog;
1257 $RT::Handle->LogSQLStatements(1);
1262 sub LogRecordedSQLStatements {
1265 my $log_sql_statements = RT->Config->Get('StatementLog');
1267 return unless ($log_sql_statements);
1269 my @log = $RT::Handle->SQLStatementLog;
1270 $RT::Handle->ClearSQLStatementLog;
1272 $RT::Handle->AddRequestToHistory({
1273 %{ $args{RequestData} },
1277 for my $stmt (@log) {
1278 my ( $time, $sql, $bind, $duration ) = @{$stmt};
1288 level => $log_sql_statements,
1290 . sprintf( "%.6f", $duration )
1292 . ( @bind ? " [ bound values: @{[map{ defined $_ ? qq|'$_'| : 'undef'} @bind]} ]" : "" )
1298 my $_has_validated_web_config = 0;
1299 sub ValidateWebConfig {
1302 # do this once per server instance, not once per request
1303 return if $_has_validated_web_config;
1304 $_has_validated_web_config = 1;
1306 my $port = $ENV{SERVER_PORT};
1307 my $host = $ENV{HTTP_X_FORWARDED_HOST} || $ENV{HTTP_X_FORWARDED_SERVER}
1308 || $ENV{HTTP_HOST} || $ENV{SERVER_NAME};
1309 ($host, $port) = ($1, $2) if $host =~ /^(.*?):(\d+)$/;
1311 if ( $port != RT->Config->Get('WebPort') and not $ENV{'rt.explicit_port'}) {
1312 $RT::Logger->warn("The requested port ($port) does NOT match the configured WebPort ($RT::WebPort). "
1313 ."Perhaps you should Set(\$WebPort, $port); in RT_SiteConfig.pm, "
1314 ."otherwise your internal links may be broken.");
1317 if ( $host ne RT->Config->Get('WebDomain') ) {
1318 $RT::Logger->warn("The requested host ($host) does NOT match the configured WebDomain ($RT::WebDomain). "
1319 ."Perhaps you should Set(\$WebDomain, '$host'); in RT_SiteConfig.pm, "
1320 ."otherwise your internal links may be broken.");
1323 # Unfortunately, there is no reliable way to get the _path_ that was
1324 # requested at the proxy level; simply disable this warning if we're
1325 # proxied and there's a mismatch.
1326 my $proxied = $ENV{HTTP_X_FORWARDED_HOST} || $ENV{HTTP_X_FORWARDED_SERVER};
1327 if ($ENV{SCRIPT_NAME} ne RT->Config->Get('WebPath') and not $proxied) {
1328 $RT::Logger->warn("The requested path ($ENV{SCRIPT_NAME}) does NOT match the configured WebPath ($RT::WebPath). "
1329 ."Perhaps you should Set(\$WebPath, '$ENV{SCRIPT_NAME}'); in RT_SiteConfig.pm, "
1330 ."otherwise your internal links may be broken.");
1334 sub ComponentRoots {
1336 my %args = ( Names => 0, @_ );
1338 if (defined $HTML::Mason::Commands::m) {
1339 @roots = $HTML::Mason::Commands::m->interp->comp_root_array;
1342 [ local => $RT::MasonLocalComponentRoot ],
1343 (map {[ "plugin-".$_->Name => $_->ComponentRoot ]} @{RT->Plugins}),
1344 [ standard => $RT::MasonComponentRoot ]
1347 @roots = map { $_->[1] } @roots unless $args{Names};
1354 $RT::LocalStaticPath,
1355 (map { $_->StaticDir } @{RT->Plugins}),
1358 return grep { $_ and -d $_ } @static;
1361 our %is_whitelisted_component = (
1362 # The RSS feed embeds an auth token in the path, but query
1363 # information for the search. Because it's a straight-up read, in
1364 # addition to embedding its own auth, it's fine.
1365 '/NoAuth/rss/dhandler' => 1,
1367 # While these can be used for denial-of-service against RT
1368 # (construct a very inefficient query and trick lots of users into
1369 # running them against RT) it's incredibly useful to be able to link
1370 # to a search result (or chart) or bookmark a result page.
1371 '/Search/Results.html' => 1,
1372 '/Search/Simple.html' => 1,
1373 '/m/tickets/search' => 1,
1374 '/Search/Chart.html' => 1,
1376 # This page takes Attachment and Transaction argument to figure
1377 # out what to show, but it's read only and will deny information if you
1378 # don't have ShowOutgoingEmail.
1379 '/Ticket/ShowEmailRecord.html' => 1,
1382 # Components which are blacklisted from automatic, argument-based whitelisting.
1383 # These pages are not idempotent when called with just an id.
1384 our %is_blacklisted_component = (
1385 # Takes only id and toggles bookmark state
1386 '/Helpers/Toggle/TicketBookmark' => 1,
1389 sub IsCompCSRFWhitelisted {
1393 return 1 if $is_whitelisted_component{$comp};
1395 my %args = %{ $ARGS };
1397 # If the user specifies a *correct* user and pass then they are
1398 # golden. This acts on the presumption that external forms may
1399 # hardcode a username and password -- if a malicious attacker knew
1400 # both already, CSRF is the least of your problems.
1401 my $AllowLoginCSRF = not RT->Config->Get('RestrictReferrerLogin');
1402 if ($AllowLoginCSRF and defined($args{user}) and defined($args{pass})) {
1403 my $user_obj = RT::CurrentUser->new();
1404 $user_obj->Load($args{user});
1405 return 1 if $user_obj->id && $user_obj->IsPassword($args{pass});
1411 # Some pages aren't idempotent even with safe args like id; blacklist
1412 # them from the automatic whitelisting below.
1413 return 0 if $is_blacklisted_component{$comp};
1415 # Eliminate arguments that do not indicate an effectful request.
1416 # For example, "id" is acceptable because that is how RT retrieves a
1420 # If they have a results= from MaybeRedirectForResults, that's also fine.
1421 delete $args{results};
1423 # The homepage refresh, which uses the Refresh header, doesn't send
1424 # a referer in most browsers; whitelist the one parameter it reloads
1425 # with, HomeRefreshInterval, which is safe
1426 delete $args{HomeRefreshInterval};
1428 # The NotMobile flag is fine for any page; it's only used to toggle a flag
1429 # in the session related to which interface you get.
1430 delete $args{NotMobile};
1432 # If there are no arguments, then it's likely to be an idempotent
1433 # request, which are not susceptible to CSRF
1439 sub IsRefererCSRFWhitelisted {
1440 my $referer = _NormalizeHost(shift);
1441 my $base_url = _NormalizeHost(RT->Config->Get('WebBaseURL'));
1442 $base_url = $base_url->host_port;
1445 for my $config ( $base_url, RT->Config->Get('ReferrerWhitelist') ) {
1446 push @$configs,$config;
1448 my $host_port = $referer->host_port;
1449 if ($config =~ /\*/) {
1450 # Turn a literal * into a domain component or partial component match.
1451 # Refer to http://tools.ietf.org/html/rfc2818#page-5
1452 my $regex = join "[a-zA-Z0-9\-]*",
1453 map { quotemeta($_) }
1454 split /\*/, $config;
1456 return 1 if $host_port =~ /^$regex$/i;
1458 return 1 if $host_port eq $config;
1462 return (0,$referer,$configs);
1465 =head3 _NormalizeHost
1467 Takes a URI and creates a URI object that's been normalized
1468 to handle common problems such as localhost vs 127.0.0.1
1472 sub _NormalizeHost {
1474 my $uri= URI->new(shift);
1475 $uri->host('127.0.0.1') if $uri->host eq 'localhost';
1481 sub IsPossibleCSRF {
1484 # If first request on this session is to a REST endpoint, then
1485 # whitelist the REST endpoints -- and explicitly deny non-REST
1486 # endpoints. We do this because using a REST cookie in a browser
1487 # would open the user to CSRF attacks to the REST endpoints.
1488 my $path = $HTML::Mason::Commands::r->path_info;
1489 $HTML::Mason::Commands::session{'REST'} = $path =~ m{^/+REST/\d+\.\d+(/|$)}
1490 unless defined $HTML::Mason::Commands::session{'REST'};
1492 if ($HTML::Mason::Commands::session{'REST'}) {
1493 return 0 if $path =~ m{^/+REST/\d+\.\d+(/|$)};
1495 This login session belongs to a REST client, and cannot be used to
1496 access non-REST interfaces of RT for security reasons.
1498 my $details = <<EOT;
1499 Please log out and back in to obtain a session for normal browsing. If
1500 you understand the security implications, disabling RT's CSRF protection
1501 will remove this restriction.
1504 HTML::Mason::Commands::Abort( $why, Details => $details );
1507 return 0 if IsCompCSRFWhitelisted(
1508 $HTML::Mason::Commands::m->request_comp->path,
1512 # if there is no Referer header then assume the worst
1514 "your browser did not supply a Referrer header", # loc
1515 ) if !$ENV{HTTP_REFERER};
1517 my ($whitelisted, $browser, $configs) = IsRefererCSRFWhitelisted($ENV{HTTP_REFERER});
1518 return 0 if $whitelisted;
1520 if ( @$configs > 1 ) {
1522 "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2]) or whitelisted hosts ([_3])", # loc
1523 $browser->host_port,
1525 join(', ', @$configs) );
1529 "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2])", # loc
1530 $browser->host_port,
1534 sub ExpandCSRFToken {
1537 my $token = delete $ARGS->{CSRF_Token};
1538 return unless $token;
1540 my $data = $HTML::Mason::Commands::session{'CSRF'}{$token};
1541 return unless $data;
1542 return unless $data->{path} eq $HTML::Mason::Commands::r->path_info;
1544 my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj;
1545 return unless $user->ValidateAuthString( $data->{auth}, $token );
1547 %{$ARGS} = %{$data->{args}};
1548 $HTML::Mason::Commands::DECODED_ARGS = $ARGS;
1550 # We explicitly stored file attachments with the request, but not in
1551 # the session yet, as that would itself be an attack. Put them into
1552 # the session now, so they'll be visible.
1553 if ($data->{attach}) {
1554 my $filename = $data->{attach}{filename};
1555 my $mime = $data->{attach}{mime};
1556 $HTML::Mason::Commands::session{'Attachments'}{$ARGS->{'Token'}||''}{$filename}
1563 sub StoreRequestToken {
1566 my $token = Digest::MD5::md5_hex(time . {} . $$ . rand(1024));
1567 my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj;
1569 auth => $user->GenerateAuthString( $token ),
1570 path => $HTML::Mason::Commands::r->path_info,
1573 if ($ARGS->{Attach}) {
1574 my $attachment = HTML::Mason::Commands::MakeMIMEEntity( AttachmentFieldName => 'Attach' );
1575 my $file_path = delete $ARGS->{'Attach'};
1577 filename => Encode::decode_utf8("$file_path"),
1578 mime => $attachment,
1582 $HTML::Mason::Commands::session{'CSRF'}->{$token} = $data;
1583 $HTML::Mason::Commands::session{'i'}++;
1587 sub MaybeShowInterstitialCSRFPage {
1590 return unless RT->Config->Get('RestrictReferrer');
1592 # Deal with the form token provided by the interstitial, which lets
1593 # browsers which never set referer headers still use RT, if
1594 # painfully. This blows values into ARGS
1595 return if ExpandCSRFToken($ARGS);
1597 my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS);
1598 return if !$is_csrf;
1600 $RT::Logger->notice("Possible CSRF: ".RT::CurrentUser->new->loc($msg, @loc));
1602 my $token = StoreRequestToken($ARGS);
1603 $HTML::Mason::Commands::m->comp(
1605 OriginalURL => RT->Config->Get('WebPath') . $HTML::Mason::Commands::r->path_info,
1606 Reason => HTML::Mason::Commands::loc( $msg, @loc ),
1609 # Calls abort, never gets here
1612 our @POTENTIAL_PAGE_ACTIONS = (
1613 qr'/Ticket/Create.html' => "create a ticket", # loc
1614 qr'/Ticket/' => "update a ticket", # loc
1615 qr'/Admin/' => "modify RT's configuration", # loc
1616 qr'/Approval/' => "update an approval", # loc
1617 qr'/Articles/' => "update an article", # loc
1618 qr'/Dashboards/' => "modify a dashboard", # loc
1619 qr'/m/ticket/' => "update a ticket", # loc
1620 qr'Prefs' => "modify your preferences", # loc
1621 qr'/Search/' => "modify or access a search", # loc
1622 qr'/SelfService/Create' => "create a ticket", # loc
1623 qr'/SelfService/' => "update a ticket", # loc
1626 sub PotentialPageAction {
1628 my @potentials = @POTENTIAL_PAGE_ACTIONS;
1629 while (my ($pattern, $result) = splice @potentials, 0, 2) {
1630 return HTML::Mason::Commands::loc($result)
1631 if $page =~ $pattern;
1636 =head2 RewriteInlineImages PARAMHASH
1638 Turns C<< <img src="cid:..."> >> elements in HTML into working images pointing
1639 back to RT's stored copy.
1641 Takes the following parameters:
1647 Scalar ref of the HTML content to rewrite. Modified in place to support the
1648 most common use-case.
1652 The L<RT::Attachment> object from which the Content originates.
1654 =item Related (optional)
1656 Array ref of related L<RT::Attachment> objects to use for C<Content-ID> matching.
1658 Defaults to the result of the C<Siblings> method on the passed Attachment.
1660 =item AttachmentPath (optional)
1662 The base path to use when rewriting C<src> attributes.
1664 Defaults to C< $WebPath/Ticket/Attachment >
1668 In scalar context, returns the number of elements rewritten.
1670 In list content, returns the attachments IDs referred to by the rewritten <img>
1671 elements, in the order found. There may be duplicates.
1675 sub RewriteInlineImages {
1678 Attachment => undef,
1680 AttachmentPath => RT->Config->Get('WebPath')."/Ticket/Attachment",
1684 return unless defined $args{Content}
1685 and ref $args{Content} eq 'SCALAR'
1686 and defined $args{Attachment};
1688 my $related_part = $args{Attachment}->Closest("multipart/related")
1691 $args{Related} ||= $related_part->Children->ItemsArrayRef;
1692 return unless @{$args{Related}};
1694 my $content = $args{'Content'};
1697 require HTML::RewriteAttributes::Resources;
1698 $$content = HTML::RewriteAttributes::Resources->rewrite($$content, sub {
1701 return $cid unless lc $meta{tag} eq 'img'
1702 and lc $meta{attr} eq 'src'
1703 and $cid =~ s/^cid://i;
1705 for my $attach (@{$args{Related}}) {
1706 if (($attach->GetHeader('Content-ID') || '') =~ /^(<)?\Q$cid\E(?(1)>)$/) {
1707 push @rewritten, $attach->Id;
1708 return "$args{AttachmentPath}/" . $attach->TransactionId . '/' . $attach->Id;
1712 # No attachments means this is a bogus CID. Just pass it through.
1713 RT->Logger->debug(qq[Found bogus inline image src="cid:$cid"]);
1719 package HTML::Mason::Commands;
1721 use vars qw/$r $m %session/;
1723 use Scalar::Util qw(blessed);
1726 return $HTML::Mason::Commands::m->notes('menu');
1730 return $HTML::Mason::Commands::m->notes('page-menu');
1734 return $HTML::Mason::Commands::m->notes('page-widgets');
1738 my %args = (toplevel => 1, parent_id => '', depth => 0, @_);
1739 return unless $args{'menu'};
1741 my ($menu, $depth, $toplevel, $id, $parent_id)
1742 = @args{qw(menu depth toplevel id parent_id)};
1744 my $interp = $m->interp;
1745 my $web_path = RT->Config->Get('WebPath');
1748 $res .= ' ' x $depth;
1750 $res .= ' id="'. $interp->apply_escapes($id, 'h') .'"'
1752 $res .= ' class="toplevel"' if $toplevel;
1755 for my $child ($menu->children) {
1756 $res .= ' 'x ($depth+1);
1758 my $item_id = lc(($parent_id? "$parent_id-" : "") .$child->key);
1759 $item_id =~ s/\s/-/g;
1760 my $eitem_id = $interp->apply_escapes($item_id, 'h');
1761 $res .= qq{<li id="li-$eitem_id"};
1764 push @classes, 'has-children' if $child->has_children;
1765 push @classes, 'active' if $child->active;
1766 $res .= ' class="'. join( ' ', @classes ) .'"'
1771 if ( my $tmp = $child->raw_html ) {
1774 $res .= qq{<a id="$eitem_id" class="menu-item};
1775 if ( $tmp = $child->class ) {
1776 $res .= ' '. $interp->apply_escapes($tmp, 'h');
1780 my $path = $child->path;
1781 my $url = (not $path or $path =~ m{^\w+:/}) ? $path : $web_path . $path;
1782 $res .= ' href="'. $interp->apply_escapes($url, 'h') .'"'
1785 if ( $tmp = $child->target ) {
1786 $res .= ' target="'. $interp->apply_escapes($tmp, 'h') .'"'
1789 if ($child->attributes) {
1790 for my $key (keys %{$child->attributes}) {
1791 my ($name, $value) = map { $interp->apply_escapes($_, 'h') }
1792 $key, $child->attributes->{$key};
1793 $res .= " $name=\"$value\"";
1798 if ( $child->escape_title ) {
1799 $res .= $interp->apply_escapes($child->title, 'h');
1801 $res .= $child->title;
1806 if ( $child->has_children ) {
1811 parent_id => $item_id,
1816 $res .= ' ' x ($depth+1);
1820 $res .= ' ' x $depth;
1822 return $res if $args{'return'};
1830 loc is a nice clean global routine which calls $session{'CurrentUser'}->loc()
1831 with whatever it's called with. If there is no $session{'CurrentUser'},
1832 it creates a temporary user, so we have something to get a localisation handle
1839 if ( $session{'CurrentUser'}
1840 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1842 return ( $session{'CurrentUser'}->loc(@_) );
1845 RT::CurrentUser->new();
1849 return ( $u->loc(@_) );
1852 # pathetic case -- SystemUser is gone.
1859 =head2 loc_fuzzy STRING
1861 loc_fuzzy is for handling localizations of messages that may already
1862 contain interpolated variables, typically returned from libraries
1863 outside RT's control. It takes the message string and extracts the
1864 variable array automatically by matching against the candidate entries
1865 inside the lexicon file.
1872 if ( $session{'CurrentUser'}
1873 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1875 return ( $session{'CurrentUser'}->loc_fuzzy($msg) );
1877 my $u = RT::CurrentUser->new( RT->SystemUser->Id );
1878 return ( $u->loc_fuzzy($msg) );
1883 # Error - calls Error and aborts
1888 if ( $session{'ErrorDocument'}
1889 && $session{'ErrorDocumentType'} )
1891 $r->content_type( $session{'ErrorDocumentType'} );
1892 $m->comp( $session{'ErrorDocument'}, Why => $why, %args );
1895 $m->comp( "/Elements/Error", Why => $why, %args );
1900 sub MaybeRedirectForResults {
1902 Path => $HTML::Mason::Commands::m->request_comp->path,
1909 my $has_actions = $args{'Actions'} && grep( defined, @{ $args{'Actions'} } );
1910 return unless $has_actions || $args{'Force'};
1912 my %arguments = %{ $args{'Arguments'} };
1914 if ( $has_actions ) {
1915 my $key = Digest::MD5::md5_hex( rand(1024) );
1916 push @{ $session{"Actions"}{ $key } ||= [] }, @{ $args{'Actions'} };
1918 $arguments{'results'} = $key;
1921 $args{'Path'} =~ s!^/+!!;
1922 my $url = RT->Config->Get('WebURL') . $args{Path};
1924 if ( keys %arguments ) {
1925 $url .= '?'. $m->comp( '/Elements/QueryString', %arguments );
1927 if ( $args{'Anchor'} ) {
1928 $url .= "#". $args{'Anchor'};
1930 return RT::Interface::Web::Redirect($url);
1933 =head2 MaybeRedirectToApproval Path => 'path', Whitelist => REGEX, ARGSRef => HASHREF
1935 If the ticket specified by C<< $ARGSRef->{id} >> is an approval ticket,
1936 redirect to the approvals display page, preserving any arguments.
1938 C<Path>s matching C<Whitelist> are let through.
1940 This is a no-op if the C<ForceApprovalsView> option isn't enabled.
1944 sub MaybeRedirectToApproval {
1946 Path => $HTML::Mason::Commands::m->request_comp->path,
1952 return unless $ENV{REQUEST_METHOD} eq 'GET';
1954 my $id = $args{ARGSRef}->{id};
1957 and RT->Config->Get('ForceApprovalsView')
1958 and not $args{Path} =~ /$args{Whitelist}/)
1960 my $ticket = RT::Ticket->new( $session{'CurrentUser'} );
1963 if ($ticket and $ticket->id and lc($ticket->Type) eq 'approval') {
1964 MaybeRedirectForResults(
1965 Path => "/Approvals/Display.html",
1967 Anchor => $args{ARGSRef}->{Anchor},
1968 Arguments => $args{ARGSRef},
1974 =head2 CreateTicket ARGS
1976 Create a new ticket, using Mason's %ARGS. returns @results.
1985 my $Ticket = RT::Ticket->new( $session{'CurrentUser'} );
1987 my $Queue = RT::Queue->new( $session{'CurrentUser'} );
1988 unless ( $Queue->Load( $ARGS{'Queue'} ) ) {
1989 Abort('Queue not found');
1992 unless ( $Queue->CurrentUserHasRight('CreateTicket') ) {
1993 Abort('You have no permission to create tickets in that queue.');
1997 if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) {
1998 $due = RT::Date->new( $session{'CurrentUser'} );
1999 $due->Set( Format => 'unknown', Value => $ARGS{'Due'} );
2002 if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) {
2003 $starts = RT::Date->new( $session{'CurrentUser'} );
2004 $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} );
2007 my $sigless = RT::Interface::Web::StripContent(
2008 Content => $ARGS{Content},
2009 ContentType => $ARGS{ContentType},
2010 StripSignature => 1,
2011 CurrentUser => $session{'CurrentUser'},
2014 my $MIMEObj = MakeMIMEEntity(
2015 Subject => $ARGS{'Subject'},
2016 From => $ARGS{'From'},
2019 Type => $ARGS{'ContentType'},
2020 Interface => RT::Interface::Web::MobileClient() ? 'Mobile' : 'Web',
2024 if ( my $tmp = $session{'Attachments'}{ $ARGS{'Token'} || '' } ) {
2025 push @attachments, grep $_, map $tmp->{$_}, sort keys %$tmp;
2027 delete $session{'Attachments'}{ $ARGS{'Token'} || '' }
2028 unless $ARGS{'KeepAttachments'};
2029 $session{'Attachments'} = $session{'Attachments'}
2032 if ( $ARGS{'Attachments'} ) {
2033 push @attachments, grep $_, map $ARGS{Attachments}->{$_}, sort keys %{ $ARGS{'Attachments'} };
2035 if ( @attachments ) {
2036 $MIMEObj->make_multipart;
2037 $MIMEObj->add_part( $_ ) foreach @attachments;
2040 for my $argument (qw(Encrypt Sign)) {
2041 if ( defined $ARGS{ $argument } ) {
2042 $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 );
2047 Type => $ARGS{'Type'} || 'ticket',
2048 Queue => $ARGS{'Queue'},
2049 Owner => $ARGS{'Owner'},
2052 Requestor => $ARGS{'Requestors'},
2054 AdminCc => $ARGS{'AdminCc'},
2055 InitialPriority => $ARGS{'InitialPriority'},
2056 FinalPriority => $ARGS{'FinalPriority'},
2057 TimeLeft => $ARGS{'TimeLeft'},
2058 TimeEstimated => $ARGS{'TimeEstimated'},
2059 TimeWorked => $ARGS{'TimeWorked'},
2060 Subject => $ARGS{'Subject'},
2061 Status => $ARGS{'Status'},
2062 Due => $due ? $due->ISO : undef,
2063 Starts => $starts ? $starts->ISO : undef,
2064 MIMEObj => $MIMEObj,
2065 TransSquelchMailTo => $ARGS{'TransSquelchMailTo'},
2068 if ($ARGS{'DryRun'}) {
2069 $create_args{DryRun} = 1;
2070 $create_args{Owner} ||= $RT::Nobody->Id;
2071 $create_args{Requestor} ||= $session{CurrentUser}->EmailAddress;
2072 $create_args{Subject} ||= '';
2073 $create_args{Status} ||= $Queue->Lifecycle->DefaultOnCreate,
2076 foreach my $type (qw(Requestor Cc AdminCc)) {
2077 push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$type} )
2078 if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] };
2080 push @{$create_args{TransSquelchMailTo}}, @txn_squelch;
2083 if ( $ARGS{'AttachTickets'} ) {
2084 require RT::Action::SendEmail;
2085 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
2086 ref $ARGS{'AttachTickets'}
2087 ? @{ $ARGS{'AttachTickets'} }
2088 : ( $ARGS{'AttachTickets'} ) );
2091 my %cfs = ProcessObjectCustomFieldUpdatesForCreate(
2093 ContextObject => $Queue,
2096 my %links = ProcessLinksForCreate( ARGSRef => \%ARGS );
2098 my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args, %links, %cfs);
2099 return $Trans if $ARGS{DryRun};
2105 push( @Actions, split( "\n", $ErrMsg ) );
2106 unless ( $Ticket->CurrentUserHasRight('ShowTicket') ) {
2107 Abort( "No permission to view newly created ticket #" . $Ticket->id . "." );
2109 return ( $Ticket, @Actions );
2115 =head2 LoadTicket id
2117 Takes a ticket id as its only variable. if it's handed an array, it takes
2120 Returns an RT::Ticket object as the current user.
2127 if ( ref($id) eq "ARRAY" ) {
2132 Abort("No ticket specified");
2135 my $Ticket = RT::Ticket->new( $session{'CurrentUser'} );
2137 unless ( $Ticket->id ) {
2138 Abort("Could not load ticket $id");
2145 =head2 ProcessUpdateMessage
2147 Takes paramhash with fields ARGSRef, TicketObj and SkipSignatureOnly.
2149 Don't write message if it only contains current user's signature and
2150 SkipSignatureOnly argument is true. Function anyway adds attachments
2151 and updates time worked field even if skips message. The default value
2156 sub ProcessUpdateMessage {
2161 SkipSignatureOnly => 1,
2166 if ( my $tmp = $session{'Attachments'}{ $args{'ARGSRef'}{'Token'} || '' } ) {
2167 push @attachments, grep $_, map $tmp->{$_}, sort keys %$tmp;
2169 delete $session{'Attachments'}{ $args{'ARGSRef'}{'Token'} || '' }
2170 unless $args{'KeepAttachments'};
2171 $session{'Attachments'} = $session{'Attachments'}
2174 if ( $args{ARGSRef}{'UpdateAttachments'} ) {
2175 push @attachments, grep $_, map $args{ARGSRef}->{UpdateAttachments}{$_},
2176 sort keys %{ $args{ARGSRef}->{'UpdateAttachments'} };
2179 # Strip the signature
2180 $args{ARGSRef}->{UpdateContent} = RT::Interface::Web::StripContent(
2181 Content => $args{ARGSRef}->{UpdateContent},
2182 ContentType => $args{ARGSRef}->{UpdateContentType},
2183 StripSignature => $args{SkipSignatureOnly},
2184 CurrentUser => $args{'TicketObj'}->CurrentUser,
2187 # If, after stripping the signature, we have no message, move the
2188 # UpdateTimeWorked into adjusted TimeWorked, so that a later
2189 # ProcessBasics can deal -- then bail out.
2190 if ( not @attachments
2191 and not length $args{ARGSRef}->{'UpdateContent'} )
2193 if ( $args{ARGSRef}->{'UpdateTimeWorked'} ) {
2194 $args{ARGSRef}->{TimeWorked} = $args{TicketObj}->TimeWorked + delete $args{ARGSRef}->{'UpdateTimeWorked'};
2199 if ( ($args{ARGSRef}->{'UpdateSubject'}||'') eq ($args{'TicketObj'}->Subject || '') ) {
2200 $args{ARGSRef}->{'UpdateSubject'} = undef;
2203 my $Message = MakeMIMEEntity(
2204 Subject => $args{ARGSRef}->{'UpdateSubject'},
2205 Body => $args{ARGSRef}->{'UpdateContent'},
2206 Type => $args{ARGSRef}->{'UpdateContentType'},
2207 Interface => RT::Interface::Web::MobileClient() ? 'Mobile' : 'Web',
2210 $Message->head->replace( 'Message-ID' => Encode::encode_utf8(
2211 RT::Interface::Email::GenMessageId( Ticket => $args{'TicketObj'} )
2213 my $old_txn = RT::Transaction->new( $session{'CurrentUser'} );
2214 if ( $args{ARGSRef}->{'QuoteTransaction'} ) {
2215 $old_txn->Load( $args{ARGSRef}->{'QuoteTransaction'} );
2217 $old_txn = $args{TicketObj}->Transactions->First();
2220 if ( my $msg = $old_txn->Message->First ) {
2221 RT::Interface::Email::SetInReplyTo(
2222 Message => $Message,
2224 Ticket => $args{'TicketObj'},
2228 if ( @attachments ) {
2229 $Message->make_multipart;
2230 $Message->add_part( $_ ) foreach @attachments;
2233 if ( $args{ARGSRef}->{'AttachTickets'} ) {
2234 require RT::Action::SendEmail;
2235 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
2236 ref $args{ARGSRef}->{'AttachTickets'}
2237 ? @{ $args{ARGSRef}->{'AttachTickets'} }
2238 : ( $args{ARGSRef}->{'AttachTickets'} ) );
2241 my %message_args = (
2242 Sign => $args{ARGSRef}->{'Sign'},
2243 Encrypt => $args{ARGSRef}->{'Encrypt'},
2244 MIMEObj => $Message,
2245 TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'}
2248 _ProcessUpdateMessageRecipients(
2249 MessageArgs => \%message_args,
2254 if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) {
2255 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment(%message_args);
2256 push( @results, $Description );
2257 $Object->UpdateCustomFields( %{ $args{ARGSRef} } ) if $Object;
2258 } elsif ( $args{ARGSRef}->{'UpdateType'} eq 'response' ) {
2259 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Correspond(%message_args);
2260 push( @results, $Description );
2261 $Object->UpdateCustomFields( %{ $args{ARGSRef} } ) if $Object;
2264 loc("Update type was neither correspondence nor comment.") . " " . loc("Update not recorded.") );
2269 sub _ProcessUpdateMessageRecipients {
2273 MessageArgs => undef,
2277 my $bcc = $args{ARGSRef}->{'UpdateBcc'};
2278 my $cc = $args{ARGSRef}->{'UpdateCc'};
2280 my $message_args = $args{MessageArgs};
2282 $message_args->{CcMessageTo} = $cc;
2283 $message_args->{BccMessageTo} = $bcc;
2286 foreach my $type (qw(Cc AdminCc)) {
2287 if (grep $_ eq $type || $_ eq ( $type . 's' ), @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
2288 push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{$type} );
2289 push @txn_squelch, $args{TicketObj}->$type->MemberEmailAddresses;
2290 push @txn_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses;
2293 if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
2294 push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{Requestor} );
2295 push @txn_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses;
2298 push @txn_squelch, @{$args{ARGSRef}{SquelchMailTo}} if $args{ARGSRef}{SquelchMailTo};
2299 $message_args->{SquelchMailTo} = \@txn_squelch
2302 unless ( $args{'ARGSRef'}->{'UpdateIgnoreAddressCheckboxes'} ) {
2303 foreach my $key ( keys %{ $args{ARGSRef} } ) {
2304 next unless $key =~ /^Update(Cc|Bcc)-(.*)$/;
2306 my $var = ucfirst($1) . 'MessageTo';
2308 if ( $message_args->{$var} ) {
2309 $message_args->{$var} .= ", $value";
2311 $message_args->{$var} = $value;
2317 sub ProcessAttachments {
2324 my $token = $args{'ARGSRef'}{'Token'}
2325 ||= $args{'Token'} ||= Digest::MD5::md5_hex( rand(1024) );
2327 my $update_session = 0;
2329 # deal with deleting uploaded attachments
2330 if ( my $del = $args{'ARGSRef'}{'DeleteAttach'} ) {
2331 delete $session{'Attachments'}{ $token }{ $_ }
2332 foreach ref $del? @$del : ($del);
2334 $update_session = 1;
2337 # store the uploaded attachment in session
2338 my $new = $args{'ARGSRef'}{'Attach'};
2339 if ( defined $new && length $new ) {
2340 my $attachment = MakeMIMEEntity(
2341 AttachmentFieldName => 'Attach'
2344 my $file_path = Encode::decode_utf8("$new");
2345 $session{'Attachments'}{ $token }{ $file_path } = $attachment;
2347 $update_session = 1;
2349 $session{'Attachments'} = $session{'Attachments'} if $update_session;
2353 =head2 MakeMIMEEntity PARAMHASH
2355 Takes a paramhash Subject, Body and AttachmentFieldName.
2357 Also takes Form, Cc and Type as optional paramhash keys.
2359 Returns a MIME::Entity.
2363 sub MakeMIMEEntity {
2365 #TODO document what else this takes.
2371 AttachmentFieldName => undef,
2376 my $Message = MIME::Entity->build(
2377 Type => 'multipart/mixed',
2378 "Message-Id" => Encode::encode_utf8( RT::Interface::Email::GenMessageId ),
2379 "X-RT-Interface" => $args{Interface},
2380 map { $_ => Encode::encode_utf8( $args{ $_} ) }
2381 grep defined $args{$_}, qw(Subject From Cc)
2384 if ( defined $args{'Body'} && length $args{'Body'} ) {
2386 # Make the update content have no 'weird' newlines in it
2387 $args{'Body'} =~ s/\r\n/\n/gs;
2390 Type => $args{'Type'} || 'text/plain',
2392 Data => $args{'Body'},
2396 if ( $args{'AttachmentFieldName'} ) {
2398 my $cgi_object = $m->cgi_object;
2399 my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} );
2400 if ( defined $filehandle && length $filehandle ) {
2402 my ( @content, $buffer );
2403 while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) {
2404 push @content, $buffer;
2407 my $uploadinfo = $cgi_object->uploadInfo($filehandle);
2409 my $filename = "$filehandle";
2410 $filename =~ s{^.*[\\/]}{};
2413 Type => $uploadinfo->{'Content-Type'},
2414 Filename => $filename,
2417 if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) {
2418 $Message->head->set( 'Subject' => $filename );
2421 # Attachment parts really shouldn't get a Message-ID or "interface"
2422 $Message->head->delete('Message-ID');
2423 $Message->head->delete('X-RT-Interface');
2427 $Message->make_singlepart;
2429 RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8
2437 =head2 ParseDateToISO
2439 Takes a date in an arbitrary format.
2440 Returns an ISO date and time in GMT
2444 sub ParseDateToISO {
2447 my $date_obj = RT::Date->new( $session{'CurrentUser'} );
2449 Format => 'unknown',
2452 return ( $date_obj->ISO );
2457 sub ProcessACLChanges {
2458 my $ARGSref = shift;
2460 #XXX: why don't we get ARGSref like in other Process* subs?
2464 foreach my $arg ( keys %$ARGSref ) {
2465 next unless ( $arg =~ /^(GrantRight|RevokeRight)-(\d+)-(.+?)-(\d+)$/ );
2467 my ( $method, $principal_id, $object_type, $object_id ) = ( $1, $2, $3, $4 );
2470 if ( UNIVERSAL::isa( $ARGSref->{$arg}, 'ARRAY' ) ) {
2471 @rights = @{ $ARGSref->{$arg} };
2473 @rights = $ARGSref->{$arg};
2475 @rights = grep $_, @rights;
2476 next unless @rights;
2478 my $principal = RT::Principal->new( $session{'CurrentUser'} );
2479 $principal->Load($principal_id);
2482 if ( $object_type eq 'RT::System' ) {
2484 } elsif ( $object_type->DOES('RT::Record::Role::Rights') ) {
2485 $obj = $object_type->new( $session{'CurrentUser'} );
2486 $obj->Load($object_id);
2487 unless ( $obj->id ) {
2488 $RT::Logger->error("couldn't load $object_type #$object_id");
2492 $RT::Logger->error("object type '$object_type' is incorrect");
2493 push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) );
2497 foreach my $right (@rights) {
2498 my ( $val, $msg ) = $principal->$method( Object => $obj, Right => $right );
2499 push( @results, $msg );
2509 ProcessACLs expects values from a series of checkboxes that describe the full
2510 set of rights a principal should have on an object.
2512 It expects form inputs with names like SetRights-PrincipalId-ObjType-ObjId
2513 instead of with the prefixes Grant/RevokeRight. Each input should be an array
2514 listing the rights the principal should have, and ProcessACLs will modify the
2515 current rights to match. Additionally, the previously unused CheckACL input
2516 listing PrincipalId-ObjType-ObjId is now used to catch cases when all the
2517 rights are removed from a principal and as such no SetRights input is
2523 my $ARGSref = shift;
2524 my (%state, @results);
2526 my $CheckACL = $ARGSref->{'CheckACL'};
2527 my @check = grep { defined } (ref $CheckACL eq 'ARRAY' ? @$CheckACL : $CheckACL);
2529 # Check if we want to grant rights to a previously rights-less user
2530 for my $type (qw(user group)) {
2531 my $principal = _ParseACLNewPrincipal($ARGSref, $type)
2534 unless ($principal->PrincipalId) {
2535 push @results, loc("Couldn't load the specified principal");
2539 my $principal_id = $principal->PrincipalId;
2541 # Turn our addprincipal rights spec into a real one
2542 for my $arg (keys %$ARGSref) {
2543 next unless $arg =~ /^SetRights-addprincipal-(.+?-\d+)$/;
2545 my $tuple = "$principal_id-$1";
2546 my $key = "SetRights-$tuple";
2548 # If we have it already, that's odd, but merge them
2549 if (grep { $_ eq $tuple } @check) {
2550 $ARGSref->{$key} = [
2551 (ref $ARGSref->{$key} eq 'ARRAY' ? @{$ARGSref->{$key}} : $ARGSref->{$key}),
2552 (ref $ARGSref->{$arg} eq 'ARRAY' ? @{$ARGSref->{$arg}} : $ARGSref->{$arg}),
2555 $ARGSref->{$key} = $ARGSref->{$arg};
2556 push @check, $tuple;
2561 # Build our rights state for each Principal-Object tuple
2562 foreach my $arg ( keys %$ARGSref ) {
2563 next unless $arg =~ /^SetRights-(\d+-.+?-\d+)$/;
2566 my $value = $ARGSref->{$arg};
2567 my @rights = grep { $_ } (ref $value eq 'ARRAY' ? @$value : $value);
2568 next unless @rights;
2570 $state{$tuple} = { map { $_ => 1 } @rights };
2573 foreach my $tuple (List::MoreUtils::uniq @check) {
2574 next unless $tuple =~ /^(\d+)-(.+?)-(\d+)$/;
2576 my ( $principal_id, $object_type, $object_id ) = ( $1, $2, $3 );
2578 my $principal = RT::Principal->new( $session{'CurrentUser'} );
2579 $principal->Load($principal_id);
2582 if ( $object_type eq 'RT::System' ) {
2584 } elsif ( $object_type->DOES('RT::Record::Role::Rights') ) {
2585 $obj = $object_type->new( $session{'CurrentUser'} );
2586 $obj->Load($object_id);
2587 unless ( $obj->id ) {
2588 $RT::Logger->error("couldn't load $object_type #$object_id");
2592 $RT::Logger->error("object type '$object_type' is incorrect");
2593 push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) );
2597 my $acls = RT::ACL->new($session{'CurrentUser'});
2598 $acls->LimitToObject( $obj );
2599 $acls->LimitToPrincipal( Id => $principal_id );
2601 while ( my $ace = $acls->Next ) {
2602 my $right = $ace->RightName;
2604 # Has right and should have right
2605 next if delete $state{$tuple}->{$right};
2607 # Has right and shouldn't have right
2608 my ($val, $msg) = $principal->RevokeRight( Object => $obj, Right => $right );
2609 push @results, $msg;
2612 # For everything left, they don't have the right but they should
2613 for my $right (keys %{ $state{$tuple} || {} }) {
2614 delete $state{$tuple}->{$right};
2615 my ($val, $msg) = $principal->GrantRight( Object => $obj, Right => $right );
2616 push @results, $msg;
2619 # Check our state for leftovers
2620 if ( keys %{ $state{$tuple} || {} } ) {
2621 my $missed = join '|', %{$state{$tuple} || {}};
2623 "Uh-oh, it looks like we somehow missed a right in "
2624 ."ProcessACLs. Here's what was leftover: $missed"
2632 =head2 _ParseACLNewPrincipal
2634 Takes a hashref of C<%ARGS> and a principal type (C<user> or C<group>). Looks
2635 for the presence of rights being added on a principal of the specified type,
2636 and returns undef if no new principal is being granted rights. Otherwise loads
2637 up an L<RT::User> or L<RT::Group> object and returns it. Note that the object
2638 may not be successfully loaded, and you should check C<->id> yourself.
2642 sub _ParseACLNewPrincipal {
2643 my $ARGSref = shift;
2644 my $type = lc shift;
2645 my $key = "AddPrincipalForRights-$type";
2647 return unless $ARGSref->{$key};
2650 if ( $type eq 'user' ) {
2651 $principal = RT::User->new( $session{'CurrentUser'} );
2652 $principal->LoadByCol( Name => $ARGSref->{$key} );
2654 elsif ( $type eq 'group' ) {
2655 $principal = RT::Group->new( $session{'CurrentUser'} );
2656 $principal->LoadUserDefinedGroup( $ARGSref->{$key} );
2662 =head2 UpdateRecordObj ( ARGSRef => \%ARGS, Object => RT::Record, AttributesRef => \@attribs)
2664 @attribs is a list of ticket fields to check and update if they differ from the B<Object>'s current values. ARGSRef is a ref to HTML::Mason's %ARGS.
2666 Returns an array of success/failure messages
2670 sub UpdateRecordObject {
2673 AttributesRef => undef,
2675 AttributePrefix => undef,
2679 my $Object = $args{'Object'};
2680 my @results = $Object->Update(
2681 AttributesRef => $args{'AttributesRef'},
2682 ARGSRef => $args{'ARGSRef'},
2683 AttributePrefix => $args{'AttributePrefix'},
2691 sub ProcessCustomFieldUpdates {
2693 CustomFieldObj => undef,
2698 my $Object = $args{'CustomFieldObj'};
2699 my $ARGSRef = $args{'ARGSRef'};
2701 my @attribs = qw(Name Type Description Queue SortOrder);
2702 my @results = UpdateRecordObject(
2703 AttributesRef => \@attribs,
2708 my $prefix = "CustomField-" . $Object->Id;
2709 if ( $ARGSRef->{"$prefix-AddValue-Name"} ) {
2710 my ( $addval, $addmsg ) = $Object->AddValue(
2711 Name => $ARGSRef->{"$prefix-AddValue-Name"},
2712 Description => $ARGSRef->{"$prefix-AddValue-Description"},
2713 SortOrder => $ARGSRef->{"$prefix-AddValue-SortOrder"},
2715 push( @results, $addmsg );
2719 = ( ref $ARGSRef->{"$prefix-DeleteValue"} eq 'ARRAY' )
2720 ? @{ $ARGSRef->{"$prefix-DeleteValue"} }
2721 : ( $ARGSRef->{"$prefix-DeleteValue"} );
2723 foreach my $id (@delete_values) {
2724 next unless defined $id;
2725 my ( $err, $msg ) = $Object->DeleteValue($id);
2726 push( @results, $msg );
2729 my $vals = $Object->Values();
2730 while ( my $cfv = $vals->Next() ) {
2731 if ( my $so = $ARGSRef->{ "$prefix-SortOrder" . $cfv->Id } ) {
2732 if ( $cfv->SortOrder != $so ) {
2733 my ( $err, $msg ) = $cfv->SetSortOrder($so);
2734 push( @results, $msg );
2744 =head2 ProcessTicketBasics ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2746 Returns an array of results messages.
2750 sub ProcessTicketBasics {
2758 my $TicketObj = $args{'TicketObj'};
2759 my $ARGSRef = $args{'ARGSRef'};
2761 my $OrigOwner = $TicketObj->Owner;
2776 # Canonicalize Queue and Owner to their IDs if they aren't numeric
2777 for my $field (qw(Queue Owner)) {
2778 if ( $ARGSRef->{$field} and ( $ARGSRef->{$field} !~ /^(\d+)$/ ) ) {
2779 my $class = $field eq 'Owner' ? "RT::User" : "RT::$field";
2780 my $temp = $class->new(RT->SystemUser);
2781 $temp->Load( $ARGSRef->{$field} );
2783 $ARGSRef->{$field} = $temp->id;
2788 # Status isn't a field that can be set to a null value.
2789 # RT core complains if you try
2790 delete $ARGSRef->{'Status'} unless $ARGSRef->{'Status'};
2792 my @results = UpdateRecordObject(
2793 AttributesRef => \@attribs,
2794 Object => $TicketObj,
2795 ARGSRef => $ARGSRef,
2798 # We special case owner changing, so we can use ForceOwnerChange
2799 if ( $ARGSRef->{'Owner'}
2800 && $ARGSRef->{'Owner'} !~ /\D/
2801 && ( $OrigOwner != $ARGSRef->{'Owner'} ) ) {
2803 if ( $ARGSRef->{'ForceOwnerChange'} ) {
2804 $ChownType = "Force";
2810 my ( $val, $msg ) = $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType );
2811 push( @results, $msg );
2819 sub ProcessTicketReminders {
2826 my $Ticket = $args{'TicketObj'};
2827 my $args = $args{'ARGSRef'};
2830 my $reminder_collection = $Ticket->Reminders->Collection;
2832 if ( $args->{'update-reminders'} ) {
2833 while ( my $reminder = $reminder_collection->Next ) {
2834 my $resolve_status = $reminder->LifecycleObj->ReminderStatusOnResolve;
2835 my ( $status, $msg, $old_subject, @subresults );
2836 if ( $reminder->Status ne $resolve_status
2837 && $args->{ 'Complete-Reminder-' . $reminder->id } )
2839 ( $status, $msg ) = $Ticket->Reminders->Resolve($reminder);
2840 push @subresults, $msg;
2842 elsif ( $reminder->Status eq $resolve_status
2843 && !$args->{ 'Complete-Reminder-' . $reminder->id } )
2845 ( $status, $msg ) = $Ticket->Reminders->Open($reminder);
2846 push @subresults, $msg;
2850 exists( $args->{ 'Reminder-Subject-' . $reminder->id } )
2851 && ( $reminder->Subject ne
2852 $args->{ 'Reminder-Subject-' . $reminder->id } )
2855 $old_subject = $reminder->Subject;
2857 $reminder->SetSubject(
2858 $args->{ 'Reminder-Subject-' . $reminder->id } );
2859 push @subresults, $msg;
2863 exists( $args->{ 'Reminder-Owner-' . $reminder->id } )
2864 && ( $reminder->Owner !=
2865 $args->{ 'Reminder-Owner-' . $reminder->id } )
2869 $reminder->SetOwner(
2870 $args->{ 'Reminder-Owner-' . $reminder->id }, "Force" );
2871 push @subresults, $msg;
2874 if ( exists( $args->{ 'Reminder-Due-' . $reminder->id } )
2875 && $args->{ 'Reminder-Due-' . $reminder->id } ne '' )
2877 my $DateObj = RT::Date->new( $session{'CurrentUser'} );
2878 my $due = $args->{ 'Reminder-Due-' . $reminder->id };
2881 Format => 'unknown',
2884 if ( defined $DateObj->Unix
2885 && $DateObj->Unix != $reminder->DueObj->Unix )
2887 ( $status, $msg ) = $reminder->SetDue( $DateObj->ISO );
2890 $msg = loc( "invalid due date: [_1]", $due );
2893 push @subresults, $msg;
2896 push @results, map {
2897 loc( "Reminder '[_1]': [_2]", $old_subject || $reminder->Subject, $_ )
2902 if ( $args->{'NewReminder-Subject'} ) {
2903 my $due_obj = RT::Date->new( $session{'CurrentUser'} );
2905 Format => 'unknown',
2906 Value => $args->{'NewReminder-Due'}
2908 my ( $status, $msg ) = $Ticket->Reminders->Add(
2909 Subject => $args->{'NewReminder-Subject'},
2910 Owner => $args->{'NewReminder-Owner'},
2911 Due => $due_obj->ISO
2915 loc( "Reminder '[_1]': [_2]", $args->{'NewReminder-Subject'}, loc("Created") )
2918 push @results, $msg;
2924 sub ProcessObjectCustomFieldUpdates {
2926 my $ARGSRef = $args{'ARGSRef'};
2929 # Build up a list of objects that we want to work with
2930 my %custom_fields_to_mod = _ParseObjectCustomFieldArgs($ARGSRef);
2932 # For each of those objects
2933 foreach my $class ( keys %custom_fields_to_mod ) {
2934 foreach my $id ( keys %{ $custom_fields_to_mod{$class} } ) {
2935 my $Object = $args{'Object'};
2936 $Object = $class->new( $session{'CurrentUser'} )
2937 unless $Object && ref $Object eq $class;
2939 $Object->Load($id) unless ( $Object->id || 0 ) == $id;
2940 unless ( $Object->id ) {
2941 $RT::Logger->warning("Couldn't load object $class #$id");
2945 foreach my $cf ( keys %{ $custom_fields_to_mod{$class}{$id} } ) {
2946 my $CustomFieldObj = RT::CustomField->new( $session{'CurrentUser'} );
2947 $CustomFieldObj->SetContextObject($Object);
2948 $CustomFieldObj->LoadById($cf);
2949 unless ( $CustomFieldObj->id ) {
2950 $RT::Logger->warning("Couldn't load custom field #$cf");
2953 my @groupings = sort keys %{ $custom_fields_to_mod{$class}{$id}{$cf} };
2954 if (@groupings > 1) {
2955 # Check for consistency, in case of JS fail
2956 for my $key (qw/AddValue Value Values DeleteValues DeleteValueIds/) {
2957 my $base = $custom_fields_to_mod{$class}{$id}{$cf}{$groupings[0]}{$key};
2958 $base = [ $base ] unless ref $base;
2959 for my $grouping (@groupings[1..$#groupings]) {
2960 my $other = $custom_fields_to_mod{$class}{$id}{$cf}{$grouping}{$key};
2961 $other = [ $other ] unless ref $other;
2962 warn "CF $cf submitted with multiple differing values"
2963 if grep {$_} List::MoreUtils::pairwise {
2964 no warnings qw(uninitialized);
2966 } @{$base}, @{$other};
2969 # We'll just be picking the 1st grouping in the hash, alphabetically
2972 _ProcessObjectCustomFieldUpdates(
2973 # XXX FIXME: Prefix is not quite right, as $id almost
2974 # certainly started as blank for new objects and is now 0.
2975 # Only Image/Binary CFs on new objects should be affected.
2976 Prefix => "Object-$class-$id-CustomField-$cf-",
2978 CustomField => $CustomFieldObj,
2979 ARGS => $custom_fields_to_mod{$class}{$id}{$cf}{$groupings[0]},
2987 sub _ParseObjectCustomFieldArgs {
2988 my $ARGSRef = shift || {};
2989 my %custom_fields_to_mod;
2991 foreach my $arg ( keys %$ARGSRef ) {
2993 # format: Object-<object class>-<object id>-CustomField[:<grouping>]-<CF id>-<commands>
2994 next unless $arg =~ /^Object-([\w:]+)-(\d*)-CustomField(?::(\w+))?-(\d+)-(.*)$/;
2996 # For each of those objects, find out what custom fields we want to work with.
2997 # Class ID CF grouping command
2998 $custom_fields_to_mod{$1}{ $2 || 0 }{$4}{$3 || ''}{$5} = $ARGSRef->{$arg};
3001 return wantarray ? %custom_fields_to_mod : \%custom_fields_to_mod;
3004 sub _ProcessObjectCustomFieldUpdates {
3006 my $cf = $args{'CustomField'};
3007 my $cf_type = $cf->Type || '';
3009 # Remove blank Values since the magic field will take care of this. Sometimes
3010 # the browser gives you a blank value which causes CFs to be processed twice
3011 if ( defined $args{'ARGS'}->{'Values'}
3012 && !length $args{'ARGS'}->{'Values'}
3013 && $args{'ARGS'}->{'Values-Magic'} )
3015 delete $args{'ARGS'}->{'Values'};
3019 foreach my $arg ( keys %{ $args{'ARGS'} } ) {
3021 # skip category argument
3022 next if $arg eq 'Category';
3024 # since http won't pass in a form element with a null value, we need
3026 if ( $arg eq 'Values-Magic' ) {
3028 # We don't care about the magic, if there's really a values element;
3029 next if defined $args{'ARGS'}->{'Value'} && length $args{'ARGS'}->{'Value'};
3030 next if defined $args{'ARGS'}->{'Values'} && length $args{'ARGS'}->{'Values'};
3032 # "Empty" values does not mean anything for Image and Binary fields
3033 next if $cf_type =~ /^(?:Image|Binary)$/;
3036 $args{'ARGS'}->{'Values'} = undef;
3039 my @values = _NormalizeObjectCustomFieldValue(
3041 Param => $args{'Prefix'} . $arg,
3042 Value => $args{'ARGS'}->{$arg}
3045 # "Empty" values still don't mean anything for Image and Binary fields
3046 next if $cf_type =~ /^(?:Image|Binary)$/ and not @values;
3048 if ( $arg eq 'AddValue' || $arg eq 'Value' ) {
3049 foreach my $value (@values) {
3050 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
3054 push( @results, $msg );
3056 } elsif ( $arg eq 'Upload' ) {
3057 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( %{$values[0]}, Field => $cf, );
3058 push( @results, $msg );
3059 } elsif ( $arg eq 'DeleteValues' ) {
3060 foreach my $value (@values) {
3061 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
3065 push( @results, $msg );
3067 } elsif ( $arg eq 'DeleteValueIds' ) {
3068 foreach my $value (@values) {
3069 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
3073 push( @results, $msg );
3075 } elsif ( $arg eq 'Values' ) {
3076 my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id );
3079 foreach my $value (@values) {
3080 if ( my $entry = $cf_values->HasEntry($value) ) {
3081 $values_hash{ $entry->id } = 1;
3085 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
3089 push( @results, $msg );
3090 $values_hash{$val} = 1 if $val;
3093 # For Date Cfs, @values is empty when there is no changes (no datas in form input)
3094 return @results if ( $cf->Type =~ /^Date(?:Time)?$/ && ! @values );
3096 $cf_values->RedoSearch;
3097 while ( my $cf_value = $cf_values->Next ) {
3098 next if $values_hash{ $cf_value->id };
3100 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
3102 ValueId => $cf_value->id
3104 push( @results, $msg );
3109 loc("User asked for an unknown update type for custom field [_1] for [_2] object #[_3]",
3110 $cf->Name, ref $args{'Object'},
3119 sub ProcessObjectCustomFieldUpdatesForCreate {
3122 ContextObject => undef,
3125 my $context = $args{'ContextObject'};
3127 my %custom_fields = _ParseObjectCustomFieldArgs( $args{'ARGSRef'} );
3129 for my $class (keys %custom_fields) {
3130 # we're only interested in new objects, so only look at $id == 0
3131 for my $cfid (keys %{ $custom_fields{$class}{0} || {} }) {
3132 my $cf = RT::CustomField->new( $session{'CurrentUser'} );
3134 my $system_cf = RT::CustomField->new( RT->SystemUser );
3135 $system_cf->LoadById($cfid);
3136 if ($system_cf->ValidateContextObject($context)) {
3137 $cf->SetContextObject($context);
3140 sprintf "Invalid context object %s (%d) for CF %d; skipping CF",
3141 ref $context, $context->id, $system_cf->id
3146 $cf->LoadById($cfid);
3149 RT->Logger->warning("Couldn't load custom field #$cfid");
3153 my @groupings = sort keys %{ $custom_fields{$class}{0}{$cfid} };
3154 if (@groupings > 1) {
3155 # Check for consistency, in case of JS fail
3156 for my $key (qw/AddValue Value Values DeleteValues DeleteValueIds/) {
3157 warn "CF $cfid submitted with multiple differing $key"
3158 if grep {($custom_fields{$class}{0}{$cfid}{$_}{$key} || '')
3159 ne ($custom_fields{$class}{0}{$cfid}{$groupings[0]}{$key} || '')}
3162 # We'll just be picking the 1st grouping in the hash, alphabetically
3166 while (my ($arg, $value) = each %{ $custom_fields{$class}{0}{$cfid}{$groupings[0]} }) {
3167 # Values-Magic doesn't matter on create; no previous values are being removed
3168 # Category is irrelevant for the actual value
3169 next if $arg eq "Values-Magic" or $arg eq "Category";
3171 push @values, _NormalizeObjectCustomFieldValue(
3173 Param => "Object-$class--CustomField-$cfid-$arg",
3178 $parsed{"CustomField-$cfid"} = \@values if @values;
3182 return wantarray ? %parsed : \%parsed;
3185 sub _NormalizeObjectCustomFieldValue {
3190 my $cf_type = $args{CustomField}->Type;
3193 if ( ref $args{'Value'} eq 'ARRAY' ) {
3194 @values = @{ $args{'Value'} };
3195 } elsif ( $cf_type =~ /text/i ) { # Both Text and Wikitext
3196 @values = ( $args{'Value'} );
3198 @values = split /\r*\n/, $args{'Value'}
3199 if defined $args{'Value'};
3201 @values = grep length, map {
3207 grep defined, @values;
3209 if ($args{'Param'} =~ /-Upload$/ and $cf_type =~ /^(Image|Binary)$/) {
3210 @values = _UploadedFile( $args{'Param'} ) || ();
3216 =head2 ProcessTicketWatchers ( TicketObj => $Ticket, ARGSRef => \%ARGS );
3218 Returns an array of results messages.
3222 sub ProcessTicketWatchers {
3230 my $Ticket = $args{'TicketObj'};
3231 my $ARGSRef = $args{'ARGSRef'};
3235 foreach my $key ( keys %$ARGSRef ) {
3237 # Delete deletable watchers
3238 if ( $key =~ /^Ticket-DeleteWatcher-Type-(.*)-Principal-(\d+)$/ ) {
3239 my ( $code, $msg ) = $Ticket->DeleteWatcher(
3243 push @results, $msg;
3246 # Delete watchers in the simple style demanded by the bulk manipulator
3247 elsif ( $key =~ /^Delete(Requestor|Cc|AdminCc)$/ ) {
3248 my ( $code, $msg ) = $Ticket->DeleteWatcher(
3249 Email => $ARGSRef->{$key},
3252 push @results, $msg;
3255 # Add new wathchers by email address
3256 elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor)$/
3257 and $key =~ /^WatcherTypeEmail(\d*)$/ )
3260 #They're in this order because otherwise $1 gets clobbered :/
3261 my ( $code, $msg ) = $Ticket->AddWatcher(
3262 Type => $ARGSRef->{$key},
3263 Email => $ARGSRef->{ "WatcherAddressEmail" . $1 }
3265 push @results, $msg;
3268 #Add requestors in the simple style demanded by the bulk manipulator
3269 elsif ( $key =~ /^Add(Requestor|Cc|AdminCc)$/ ) {
3270 my ( $code, $msg ) = $Ticket->AddWatcher(
3272 Email => $ARGSRef->{$key}
3274 push @results, $msg;
3277 # Add new watchers by owner
3278 elsif ( $key =~ /^Ticket-AddWatcher-Principal-(\d*)$/ ) {
3279 my $principal_id = $1;
3280 my $form = $ARGSRef->{$key};
3281 foreach my $value ( ref($form) ? @{$form} : ($form) ) {
3282 next unless $value =~ /^(?:AdminCc|Cc|Requestor)$/i;
3284 my ( $code, $msg ) = $Ticket->AddWatcher(
3286 PrincipalId => $principal_id
3288 push @results, $msg;
3298 =head2 ProcessTicketDates ( TicketObj => $Ticket, ARGSRef => \%ARGS );
3300 Returns an array of results messages.
3304 sub ProcessTicketDates {
3311 my $Ticket = $args{'TicketObj'};
3312 my $ARGSRef = $args{'ARGSRef'};
3317 my @date_fields = qw(
3324 #Run through each field in this list. update the value if apropriate
3325 foreach my $field (@date_fields) {
3326 next unless exists $ARGSRef->{ $field . '_Date' };
3327 next if $ARGSRef->{ $field . '_Date' } eq '';
3331 my $DateObj = RT::Date->new( $session{'CurrentUser'} );
3333 Format => 'unknown',
3334 Value => $ARGSRef->{ $field . '_Date' }
3337 my $obj = $field . "Obj";
3338 if ( ( defined $DateObj->Unix )
3339 and ( $DateObj->Unix != $Ticket->$obj()->Unix() ) )
3341 my $method = "Set$field";
3342 my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO );
3343 push @results, "$msg";
3353 =head2 ProcessTicketLinks ( TicketObj => $Ticket, ARGSRef => \%ARGS );
3355 Returns an array of results messages.
3359 sub ProcessTicketLinks {
3367 my $Ticket = $args{'TicketObj'};
3368 my $TicketId = $args{'TicketId'} || $Ticket->Id;
3369 my $ARGSRef = $args{'ARGSRef'};
3371 my (@results) = ProcessRecordLinks(
3372 %args, RecordObj => $Ticket, RecordId => $TicketId, ARGSRef => $ARGSRef,
3375 #Merge if we need to
3376 my $input = $TicketId .'-MergeInto';
3377 if ( $ARGSRef->{ $input } ) {
3378 $ARGSRef->{ $input } =~ s/\s+//g;
3379 my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $input } );
3380 push @results, $msg;
3387 sub ProcessRecordLinks {
3395 my $Record = $args{'RecordObj'};
3396 my $RecordId = $args{'RecordId'} || $Record->Id;
3397 my $ARGSRef = $args{'ARGSRef'};
3401 # Delete links that are gone gone gone.
3402 foreach my $arg ( keys %$ARGSRef ) {
3403 if ( $arg =~ /DeleteLink-(.*?)-(DependsOn|MemberOf|RefersTo)-(.*)$/ ) {
3408 my ( $val, $msg ) = $Record->DeleteLink(
3414 push @results, $msg;
3420 my @linktypes = qw( DependsOn MemberOf RefersTo );
3422 foreach my $linktype (@linktypes) {
3423 my $input = $RecordId .'-'. $linktype;
3424 if ( $ARGSRef->{ $input } ) {
3425 $ARGSRef->{ $input } = join( ' ', @{ $ARGSRef->{ $input } } )
3426 if ref $ARGSRef->{ $input };
3428 for my $luri ( split( / /, $ARGSRef->{ $input } ) ) {
3430 $luri =~ s/\s+$//; # Strip trailing whitespace
3431 my ( $val, $msg ) = $Record->AddLink(
3435 push @results, $msg;
3438 $input = $linktype .'-'. $RecordId;
3439 if ( $ARGSRef->{ $input } ) {
3440 $ARGSRef->{ $input } = join( ' ', @{ $ARGSRef->{ $input } } )
3441 if ref $ARGSRef->{ $input };
3443 for my $luri ( split( / /, $ARGSRef->{ $input } ) ) {
3445 my ( $val, $msg ) = $Record->AddLink(
3450 push @results, $msg;
3458 =head2 ProcessLinksForCreate
3460 Takes a hash with a single key, C<ARGSRef>, the value of which is a hashref to
3463 Converts and returns submitted args in the form of C<new-LINKTYPE> and
3464 C<LINKTYPE-new> into their appropriate directional link types. For example,
3465 C<new-DependsOn> becomes C<DependsOn> and C<DependsOn-new> becomes
3466 C<DependedOnBy>. The incoming arg values are split on whitespace and
3467 normalized into arrayrefs before being returned.
3469 Primarily used by object creation pages for transforming incoming form inputs
3470 from F</Elements/EditLinks> into arguments appropriate for individual record
3473 Returns a hashref in scalar context and a hash in list context.
3477 sub ProcessLinksForCreate {
3481 foreach my $type ( keys %RT::Link::DIRMAP ) {
3482 for ([Base => "new-$type"], [Target => "$type-new"]) {
3483 my ($direction, $key) = @$_;
3484 next unless $args{ARGSRef}->{$key};
3485 $links{ $RT::Link::DIRMAP{$type}->{$direction} } = [
3486 grep $_, split ' ', $args{ARGSRef}->{$key}
3490 return wantarray ? %links : \%links;
3493 =head2 ProcessTransactionSquelching
3495 Takes a hashref of the submitted form arguments, C<%ARGS>.
3497 Returns a hash of squelched addresses.
3501 sub ProcessTransactionSquelching {
3503 my %checked = map { $_ => 1 } grep { defined }
3504 ( ref $args->{'TxnSendMailTo'} eq "ARRAY" ? @{$args->{'TxnSendMailTo'}} :
3505 defined $args->{'TxnSendMailTo'} ? ($args->{'TxnSendMailTo'}) :
3507 my %squelched = map { $_ => 1 } grep { not $checked{$_} } split /,/, ($args->{'TxnRecipients'}||'');
3511 sub ProcessRecordBulkCustomFields {
3512 my %args = (RecordObj => undef, ARGSRef => {}, @_);
3514 my $ARGSRef = $args{'ARGSRef'};
3519 foreach my $key ( keys %$ARGSRef ) {
3520 next unless $key =~ /^Bulk-(Add|Delete)-CustomField-(\d+)-(.*)$/;
3521 my ($op, $cfid, $rest) = ($1, $2, $3);
3522 next if $rest eq "Category";
3524 my $res = $data{$cfid} ||= {};
3525 unless (keys %$res) {
3526 my $cf = RT::CustomField->new( $session{'CurrentUser'} );
3528 next unless $cf->Id;
3533 if ( $op eq 'Delete' && $rest eq 'AllValues' ) {
3534 $res->{'DeleteAll'} = $ARGSRef->{$key};
3538 my @values = _NormalizeObjectCustomFieldValue(
3539 CustomField => $res->{'cf'},
3540 Value => $ARGSRef->{$key},
3543 next unless @values;
3544 $res->{$op} = \@values;
3547 while ( my ($cfid, $data) = each %data ) {
3548 # just add one value for fields with single value
3549 if ( $data->{'Add'} && $data->{'cf'}->MaxValues == 1 ) {
3550 my ( $id, $msg ) = $args{'RecordObj'}->AddCustomFieldValue(
3552 Value => $data->{'Add'}[-1],
3554 push @results, $msg;
3558 my $current_values = $args{'RecordObj'}->CustomFieldValues( $cfid );
3559 if ( $data->{'DeleteAll'} ) {
3560 while ( my $value = $current_values->Next ) {
3561 my ( $id, $msg ) = $args{'RecordObj'}->DeleteCustomFieldValue(
3563 ValueId => $value->id,
3565 push @results, $msg;
3568 foreach my $value ( @{ $data->{'Delete'} || [] } ) {
3569 next unless $current_values->HasEntry($value);
3571 my ( $id, $msg ) = $args{'RecordObj'}->DeleteCustomFieldValue(
3575 push @results, $msg;
3577 foreach my $value ( @{ $data->{'Add'} || [] } ) {
3578 next if $current_values->HasEntry($value);
3580 my ( $id, $msg ) = $args{'RecordObj'}->AddCustomFieldValue(
3584 push @results, $msg;
3590 =head2 _UploadedFile ( $arg );
3592 Takes a CGI parameter name; if a file is uploaded under that name,
3593 return a hash reference suitable for AddCustomFieldValue's use:
3594 C<( Value => $filename, LargeContent => $content, ContentType => $type )>.
3596 Returns C<undef> if no files were uploaded in the C<$arg> field.
3602 my $cgi_object = $m->cgi_object;
3603 my $fh = $cgi_object->upload($arg) or return undef;
3604 my $upload_info = $cgi_object->uploadInfo($fh);
3606 my $filename = "$fh";
3607 $filename =~ s#^.*[\\/]##;
3612 LargeContent => do { local $/; scalar <$fh> },
3613 ContentType => $upload_info->{'Content-Type'},
3617 sub GetColumnMapEntry {
3618 my %args = ( Map => {}, Name => '', Attribute => undef, @_ );
3620 # deal with the simplest thing first
3621 if ( $args{'Map'}{ $args{'Name'} } ) {
3622 return $args{'Map'}{ $args{'Name'} }{ $args{'Attribute'} };
3626 elsif ( my ( $mainkey, $subkey ) = $args{'Name'} =~ /^(.*?)\.(.+)$/ ) {
3627 $subkey =~ s/^\{(.*)\}$/$1/;
3628 return undef unless $args{'Map'}->{$mainkey};
3629 return $args{'Map'}{$mainkey}{ $args{'Attribute'} }
3630 unless ref $args{'Map'}{$mainkey}{ $args{'Attribute'} } eq 'CODE';
3632 return sub { $args{'Map'}{$mainkey}{ $args{'Attribute'} }->( @_, $subkey ) };
3637 sub ProcessColumnMapValue {
3639 my %args = ( Arguments => [], Escape => 1, @_ );
3642 if ( UNIVERSAL::isa( $value, 'CODE' ) ) {
3643 my @tmp = $value->( @{ $args{'Arguments'} } );
3644 return ProcessColumnMapValue( ( @tmp > 1 ? \@tmp : $tmp[0] ), %args );
3645 } elsif ( UNIVERSAL::isa( $value, 'ARRAY' ) ) {
3646 return join '', map ProcessColumnMapValue( $_, %args ), @$value;
3647 } elsif ( UNIVERSAL::isa( $value, 'SCALAR' ) ) {
3651 if ($args{'Escape'}) {
3652 $value = $m->interp->apply_escapes( $value, 'h' );
3653 $value =~ s/\n/<br>/g if defined $value;
3659 =head2 GetPrincipalsMap OBJECT, CATEGORIES
3661 Returns an array suitable for passing to /Admin/Elements/EditRights with the
3662 principal collections mapped from the categories given.
3666 sub GetPrincipalsMap {
3671 my $system = RT::Groups->new($session{'CurrentUser'});
3672 $system->LimitToSystemInternalGroups();
3673 $system->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3675 'System' => $system, # loc_left_pair
3680 my $groups = RT::Groups->new($session{'CurrentUser'});
3681 $groups->LimitToUserDefinedGroups();
3682 $groups->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3684 # Only show groups who have rights granted on this object
3685 $groups->WithGroupRight(
3688 IncludeSystemRights => 0,
3689 IncludeSubgroupMembers => 0,
3693 'User Groups' => $groups, # loc_left_pair
3698 my $roles = RT::Groups->new($session{'CurrentUser'});
3700 if ($object->isa("RT::CustomField")) {
3701 # If we're a custom field, show the global roles for our LookupType.
3702 my $class = $object->RecordClassFromLookupType;
3703 if ($class and $class->DOES("RT::Record::Role::Roles")) {
3704 $roles->LimitToRolesForObject(RT->System);
3705 $roles->Limit( FIELD => "Name", VALUE => $_, CASESENSITIVE => 0 )
3708 # No roles to show; so show nothing
3712 $roles->LimitToRolesForObject($object);
3716 $roles->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3718 'Roles' => $roles, # loc_left_pair
3724 my $Users = RT->PrivilegedUsers->UserMembersObj();
3725 $Users->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3727 # Only show users who have rights granted on this object
3728 my $group_members = $Users->WhoHaveGroupRight(
3731 IncludeSystemRights => 0,
3732 IncludeSubgroupMembers => 0,
3735 # Limit to UserEquiv groups
3736 my $groups = $Users->Join(
3737 ALIAS1 => $group_members,
3738 FIELD1 => 'GroupId',
3742 $Users->Limit( ALIAS => $groups, FIELD => 'Domain', VALUE => 'ACLEquivalence', CASESENSITIVE => 0 );
3743 $Users->Limit( ALIAS => $groups, FIELD => 'Name', VALUE => 'UserEquiv', CASESENSITIVE => 0 );
3746 'Users' => $Users, # loc_left_pair
3754 =head2 _load_container_object ( $type, $id );
3756 Instantiate container object for saving searches.
3760 sub _load_container_object {
3761 my ( $obj_type, $obj_id ) = @_;
3762 return RT::SavedSearch->new( $session{'CurrentUser'} )->_load_privacy_object( $obj_type, $obj_id );
3765 =head2 _parse_saved_search ( $arg );
3767 Given a serialization string for saved search, and returns the
3768 container object and the search id.
3772 sub _parse_saved_search {
3774 return unless $spec;
3775 if ( $spec !~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) {
3782 return ( _load_container_object( $obj_type, $obj_id ), $search_id );
3785 =head2 ScrubHTML content
3787 Removes unsafe and undesired HTML from the passed content
3793 my $Content = shift;
3794 $SCRUBBER = _NewScrubber() unless $SCRUBBER;
3796 $Content = '' if !defined($Content);
3797 return $SCRUBBER->scrub($Content);
3802 Returns a new L<HTML::Scrubber> object.
3804 If you need to be more lax about what HTML tags and attributes are allowed,
3805 create C</opt/rt4/local/lib/RT/Interface/Web_Local.pm> with something like the
3808 package HTML::Mason::Commands;
3809 # Let tables through
3810 push @SCRUBBER_ALLOWED_TAGS, qw(TABLE THEAD TBODY TFOOT TR TD TH);
3815 our @SCRUBBER_ALLOWED_TAGS = qw(
3816 A B U P BR I HR BR SMALL EM FONT SPAN STRONG SUB SUP STRIKE H1 H2 H3 H4 H5
3817 H6 DIV UL OL LI DL DT DD PRE BLOCKQUOTE BDO
3820 our %SCRUBBER_ALLOWED_ATTRIBUTES = (
3821 # Match http, https, ftp, mailto and relative urls
3822 # XXX: we also scrub format strings with this module then allow simple config options
3823 href => qr{^(?:https?:|ftp:|mailto:|/|__Web(?:Path|HomePath|BaseURL|URL)__)}i,
3829 (?:(?:background-)?color: \s*
3830 (?:rgb\(\s* \d+, \s* \d+, \s* \d+ \s*\) | # rgb(d,d,d)
3831 \#[a-f0-9]{3,6} | # #fff or #ffffff
3832 [\w\-]+ # green, light-blue, etc.
3834 text-align: \s* \w+ |
3835 font-size: \s* [\w.\-]+ |
3836 font-family: \s* [\w\s"',.\-]+ |
3837 font-weight: \s* [\w\-]+ |
3839 # MS Office styles, which are probably fine. If we don't, then any
3840 # associated styles in the same attribute get stripped.
3841 mso-[\w\-]+?: \s* [\w\s"',.\-]+
3843 +$ # one or more of these allowed properties from here 'till sunset
3845 dir => qr/^(rtl|ltr)$/i,
3846 lang => qr/^\w+(-\w+)?$/,
3849 our %SCRUBBER_RULES = ();
3851 # If we're displaying images, let embedded ones through
3852 if (RT->Config->Get('ShowTransactionImages') or RT->Config->Get('ShowRemoteImages')) {
3853 $SCRUBBER_RULES{'img'} = {
3859 push @src, qr/^cid:/i
3860 if RT->Config->Get('ShowTransactionImages');
3862 push @src, $SCRUBBER_ALLOWED_ATTRIBUTES{'href'}
3863 if RT->Config->Get('ShowRemoteImages');
3865 $SCRUBBER_RULES{'img'}->{'src'} = join "|", @src;
3869 require HTML::Scrubber;
3870 my $scrubber = HTML::Scrubber->new();
3874 %SCRUBBER_ALLOWED_ATTRIBUTES,
3875 '*' => 0, # require attributes be explicitly allowed
3878 $scrubber->deny(qw[*]);
3879 $scrubber->allow(@SCRUBBER_ALLOWED_TAGS);
3880 $scrubber->rules(%SCRUBBER_RULES);
3882 # Scrubbing comments is vital since IE conditional comments can contain
3883 # arbitrary HTML and we'd pass it right on through.
3884 $scrubber->comment(0);
3891 Redispatches to L<RT::Interface::Web/EncodeJSON>
3896 RT::Interface::Web::EncodeJSON(@_);
3901 return '' unless defined $value;
3902 $value =~ s/[^A-Za-z0-9_-]/_/g;
3906 package RT::Interface::Web;
3907 RT::Base->_ImportOverlays();