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;
71 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 /static/RichText/ckeditor.js
131 }, RT->Config->Get('JSFiles');
136 Removes the cached CSS and JS entries, forcing them to be regenerated
146 =head2 EscapeHTML SCALARREF
148 does a css-busting but minimalist escaping of whatever html you're passing in.
154 return unless defined $$ref;
156 $$ref =~ s/&/&/g;
159 $$ref =~ s/\(/(/g;
160 $$ref =~ s/\)/)/g;
161 $$ref =~ s/"/"/g;
162 $$ref =~ s/'/'/g;
169 Instead => "EscapeHTML",
175 =head2 EscapeURI SCALARREF
177 Escapes URI component according to RFC2396
183 return unless defined $$ref;
186 $$ref =~ s/([^a-zA-Z0-9_.!~*'()-])/uc sprintf("%%%02X", ord($1))/eg;
189 =head2 EncodeJSON SCALAR
191 Encodes the SCALAR to JSON and returns a JSON Unicode (B<not> UTF-8) string.
192 SCALAR may be a simple value or a reference.
197 my $s = JSON::to_json(shift, { allow_nonref => 1 });
202 sub _encode_surrogates {
203 my $uni = $_[0] - 0x10000;
204 return ($uni / 0x400 + 0xD800, $uni % 0x400 + 0xDC00);
209 return unless defined $$ref;
211 $$ref = "'" . join('',
213 chr($_) =~ /[a-zA-Z0-9]/ ? chr($_) :
214 $_ <= 255 ? sprintf("\\x%02X", $_) :
215 $_ <= 65535 ? sprintf("\\u%04X", $_) :
216 sprintf("\\u%X\\u%X", _encode_surrogates($_))
217 } unpack('U*', $$ref))
221 =head2 WebCanonicalizeInfo();
223 Different web servers set different environmental varibles. This
224 function must return something suitable for REMOTE_USER. By default,
225 just downcase $ENV{'REMOTE_USER'}
229 sub WebCanonicalizeInfo {
230 return $ENV{'REMOTE_USER'} ? lc $ENV{'REMOTE_USER'} : $ENV{'REMOTE_USER'};
235 =head2 WebRemoteUserAutocreateInfo($user);
237 Returns a hash of user attributes, used when WebRemoteUserAutocreate is set.
241 sub WebRemoteUserAutocreateInfo {
246 # default to making Privileged users, even if they specify
247 # some other default Attributes
248 if ( !$RT::UserAutocreateDefaultsOnLogin
249 || ( ref($RT::UserAutocreateDefaultsOnLogin) && not exists $RT::UserAutocreateDefaultsOnLogin->{Privileged} ) )
251 $user_info{'Privileged'} = 1;
254 # Populate fields with information from Unix /etc/passwd
255 my ( $comments, $realname ) = ( getpwnam($user) )[ 5, 6 ];
256 $user_info{'Comments'} = $comments if defined $comments;
257 $user_info{'RealName'} = $realname if defined $realname;
259 # and return the wad of stuff
267 if (RT->Config->Get('DevelMode')) {
268 require Module::Refresh;
269 Module::Refresh->refresh;
272 $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8");
274 $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ];
276 # Roll back any dangling transactions from a previous failed connection
277 $RT::Handle->ForceRollback() if $RT::Handle and $RT::Handle->TransactionDepth;
279 MaybeEnableSQLStatementLog();
281 # avoid reentrancy, as suggested by masonbook
282 local *HTML::Mason::Commands::session unless $HTML::Mason::Commands::m->is_subrequest;
284 $HTML::Mason::Commands::m->autoflush( $HTML::Mason::Commands::m->request_comp->attr('AutoFlush') )
285 if ( $HTML::Mason::Commands::m->request_comp->attr_exists('AutoFlush') );
290 local $HTML::Mason::Commands::DECODED_ARGS = $ARGS;
291 PreprocessTimeUpdates($ARGS);
294 MaybeShowInstallModePage();
296 $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS );
299 if ( _UserLoggedIn() ) {
300 # make user info up to date
301 $HTML::Mason::Commands::session{'CurrentUser'}
302 ->Load( $HTML::Mason::Commands::session{'CurrentUser'}->id );
303 undef $HTML::Mason::Commands::session{'CurrentUser'}->{'LangHandle'};
306 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
309 # Process session-related callbacks before any auth attempts
310 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Session', CallbackPage => '/autohandler' );
312 MaybeRejectPrivateComponentRequest();
314 MaybeShowNoAuthPage($ARGS);
316 AttemptExternalAuth($ARGS) if RT->Config->Get('WebRemoteUserContinuous') or not _UserLoggedIn();
318 _ForceLogout() unless _UserLoggedIn();
320 # Process per-page authentication callbacks
321 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Auth', CallbackPage => '/autohandler' );
323 if ( $ARGS->{'NotMobile'} ) {
324 $HTML::Mason::Commands::session{'NotMobile'} = 1;
327 unless ( _UserLoggedIn() ) {
330 # Authenticate if the user is trying to login via user/pass query args
331 my ($authed, $msg) = AttemptPasswordAuthentication($ARGS);
334 my $m = $HTML::Mason::Commands::m;
336 # REST urls get a special 401 response
337 if ($m->request_comp->path =~ m{^/REST/\d+\.\d+/}) {
338 $HTML::Mason::Commands::r->content_type("text/plain; charset=utf-8");
339 $m->error_format("text");
340 $m->out("RT/$RT::VERSION 401 Credentials required\n");
341 $m->out("\n$msg\n") if $msg;
344 # Specially handle /index.html and /m/index.html so that we get a nicer URL
345 elsif ( $m->request_comp->path =~ m{^(/m)?/index\.html$} ) {
346 my $mobile = $1 ? 1 : 0;
347 my $next = SetNextPage($ARGS);
348 $m->comp('/NoAuth/Login.html',
355 TangentForLogin($ARGS, results => ($msg ? LoginError($msg) : undef));
360 MaybeShowInterstitialCSRFPage($ARGS);
362 # now it applies not only to home page, but any dashboard that can be used as a workspace
363 $HTML::Mason::Commands::session{'home_refresh_interval'} = $ARGS->{'HomeRefreshInterval'}
364 if ( $ARGS->{'HomeRefreshInterval'} );
366 # Process per-page global callbacks
367 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Default', CallbackPage => '/autohandler' );
369 ShowRequestedPage($ARGS);
370 LogRecordedSQLStatements(RequestData => {
371 Path => $HTML::Mason::Commands::m->request_path,
374 # Process per-page final cleanup callbacks
375 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Final', CallbackPage => '/autohandler' );
377 $HTML::Mason::Commands::m->comp( '/Elements/Footer', %$ARGS );
382 delete $HTML::Mason::Commands::session{'CurrentUser'};
386 if ( $HTML::Mason::Commands::session{CurrentUser} && $HTML::Mason::Commands::session{'CurrentUser'}->id ) {
394 =head2 LoginError ERROR
396 Pushes a login error into the Actions session store and returns the hash key.
402 my $key = Digest::MD5::md5_hex( rand(1024) );
403 push @{ $HTML::Mason::Commands::session{"Actions"}->{$key} ||= [] }, $new;
404 $HTML::Mason::Commands::session{'i'}++;
408 =head2 SetNextPage ARGSRef [PATH]
410 Intuits and stashes the next page in the sesssion hash. If PATH is
411 specified, uses that instead of the value of L<IntuitNextPage()>. Returns
418 my $next = $_[0] ? $_[0] : IntuitNextPage();
419 my $hash = Digest::MD5::md5_hex($next . $$ . rand(1024));
420 my $page = { url => $next };
422 # If an explicit URL was passed and we didn't IntuitNextPage, then
423 # IsPossibleCSRF below is almost certainly unrelated to the actual
424 # destination. Currently explicit next pages aren't used in RT, but the
426 if (not $_[0] and RT->Config->Get("RestrictReferrer")) {
427 # This isn't really CSRF, but the CSRF heuristics are useful for catching
428 # requests which may have unintended side-effects.
429 my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS);
432 "Marking original destination as having side-effects before redirecting for login.\n"
434 ."Reason: " . HTML::Mason::Commands::loc($msg, @loc)
436 $page->{'HasSideEffects'} = [$msg, @loc];
440 $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $page;
441 $HTML::Mason::Commands::session{'i'}++;
445 =head2 FetchNextPage HASHKEY
447 Returns the stashed next page hashref for the given hash.
452 my $hash = shift || "";
453 return $HTML::Mason::Commands::session{'NextPage'}->{$hash};
456 =head2 RemoveNextPage HASHKEY
458 Removes the stashed next page for the given hash and returns it.
463 my $hash = shift || "";
464 return delete $HTML::Mason::Commands::session{'NextPage'}->{$hash};
467 =head2 TangentForLogin ARGSRef [HASH]
469 Redirects to C</NoAuth/Login.html>, setting the value of L<IntuitNextPage> as
470 the next page. Takes a hashref of request %ARGS as the first parameter.
471 Optionally takes all other parameters as a hash which is dumped into query
476 sub TangentForLogin {
477 my $login = TangentForLoginURL(@_);
478 Redirect( RT->Config->Get('WebBaseURL') . $login );
481 =head2 TangentForLoginURL [HASH]
483 Returns a URL suitable for tangenting for login. Optionally takes a hash which
484 is dumped into query params.
488 sub TangentForLoginURL {
490 my $hash = SetNextPage($ARGS);
491 my %query = (@_, next => $hash);
494 if $HTML::Mason::Commands::m->request_comp->path =~ m{^/m(/|$)};
496 my $login = RT->Config->Get('WebPath') . '/NoAuth/Login.html?';
497 $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query);
501 =head2 TangentForLoginWithError ERROR
503 Localizes the passed error message, stashes it with L<LoginError> and then
504 calls L<TangentForLogin> with the appropriate results key.
508 sub TangentForLoginWithError {
510 my $key = LoginError(HTML::Mason::Commands::loc(@_));
511 TangentForLogin( $ARGS, results => $key );
514 =head2 IntuitNextPage
516 Attempt to figure out the path to which we should return the user after a
517 tangent. The current request URL is used, or failing that, the C<WebURL>
518 configuration variable.
525 # This includes any query parameters. Redirect will take care of making
526 # it an absolute URL.
527 if ($ENV{'REQUEST_URI'}) {
528 $req_uri = $ENV{'REQUEST_URI'};
530 # collapse multiple leading slashes so the first part doesn't look like
531 # a hostname of a schema-less URI
532 $req_uri =~ s{^/+}{/};
535 my $next = defined $req_uri ? $req_uri : RT->Config->Get('WebURL');
538 my $uri = URI->new($next);
540 # You get undef scheme with a relative uri like "/Search/Build.html"
541 unless (!defined($uri->scheme) || $uri->scheme eq 'http' || $uri->scheme eq 'https') {
542 $next = RT->Config->Get('WebURL');
545 # Make sure we're logging in to the same domain
546 # You can get an undef authority with a relative uri like "index.html"
547 my $uri_base_url = URI->new(RT->Config->Get('WebBaseURL'));
548 unless (!defined($uri->authority) || $uri->authority eq $uri_base_url->authority) {
549 $next = RT->Config->Get('WebURL');
555 =head2 MaybeShowInstallModePage
557 This function, called exclusively by RT's autohandler, dispatches
558 a request to RT's Installation workflow, only if Install Mode is enabled in the configuration file.
560 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
564 sub MaybeShowInstallModePage {
565 return unless RT->InstallMode;
567 my $m = $HTML::Mason::Commands::m;
568 if ( $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex') ) {
570 } elsif ( $m->request_comp->path !~ m{^(/+)Install/} ) {
571 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "Install/index.html" );
578 =head2 MaybeShowNoAuthPage \%ARGS
580 This function, called exclusively by RT's autohandler, dispatches
581 a request to the page a user requested (but only if it matches the "noauth" regex.
583 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
587 sub MaybeShowNoAuthPage {
590 my $m = $HTML::Mason::Commands::m;
592 return unless $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex');
594 # Don't show the login page to logged in users
595 Redirect(RT->Config->Get('WebURL'))
596 if $m->base_comp->path eq '/NoAuth/Login.html' and _UserLoggedIn();
598 # If it's a noauth file, don't ask for auth.
599 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
603 =head2 MaybeRejectPrivateComponentRequest
605 This function will reject calls to private components, like those under
606 C</Elements>. If the requested path is a private component then we will
607 abort with a C<403> error.
611 sub MaybeRejectPrivateComponentRequest {
612 my $m = $HTML::Mason::Commands::m;
613 my $path = $m->request_comp->path;
615 # We do not check for dhandler here, because requesting our dhandlers
616 # directly is okay. Mason will invoke the dhandler with a dhandler_arg of
622 _elements | # mobile UI
625 autohandler | # requesting this directly is suspicious
626 l (_unsafe)? ) # loc component
627 ( $ | / ) # trailing slash or end of path
636 $HTML::Mason::Commands::m->notes('menu', RT::Interface::Web::Menu->new());
637 $HTML::Mason::Commands::m->notes('page-menu', RT::Interface::Web::Menu->new());
638 $HTML::Mason::Commands::m->notes('page-widgets', RT::Interface::Web::Menu->new());
643 =head2 ShowRequestedPage \%ARGS
645 This function, called exclusively by RT's autohandler, dispatches
646 a request to the page a user requested (making sure that unpriviled users
647 can only see self-service pages.
651 sub ShowRequestedPage {
654 my $m = $HTML::Mason::Commands::m;
656 # Ensure that the cookie that we send is up-to-date, in case the
657 # session-id has been modified in any way
660 # precache all system level rights for the current user
661 $HTML::Mason::Commands::session{CurrentUser}->PrincipalObj->HasRights( Object => RT->System );
663 # If the user isn't privileged, they can only see SelfService
664 unless ( $HTML::Mason::Commands::session{'CurrentUser'}->Privileged ) {
666 # if the user is trying to access a ticket, redirect them
667 if ( $m->request_comp->path =~ m{^(/+)Ticket/Display.html} && $ARGS->{'id'} ) {
668 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/Display.html?id=" . $ARGS->{'id'} );
671 # otherwise, drop the user at the SelfService default page
672 elsif ( $m->base_comp->path !~ RT->Config->Get('SelfServiceRegex') ) {
673 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/" );
676 # if user is in SelfService dir let him do anything
678 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
681 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
686 sub AttemptExternalAuth {
689 return unless ( RT->Config->Get('WebRemoteUserAuth') );
691 my $user = $ARGS->{user};
692 my $m = $HTML::Mason::Commands::m;
694 my $logged_in_external_user = _UserLoggedIn() && $HTML::Mason::Commands::session{'WebExternallyAuthed'};
696 # If RT is configured for external auth, let's go through and get REMOTE_USER
698 # Do we actually have a REMOTE_USER or equivalent? We only check auth if
699 # 1) we have no logged in user, or 2) we have a user who is externally
700 # authed. If we have a logged in user who is internally authed, don't
701 # check remote user otherwise we may log them out.
702 if (RT::Interface::Web::WebCanonicalizeInfo()
703 and (not _UserLoggedIn() or $logged_in_external_user) )
705 $user = RT::Interface::Web::WebCanonicalizeInfo();
706 my $load_method = RT->Config->Get('WebRemoteUserGecos') ? 'LoadByGecos' : 'Load';
708 my $next = RemoveNextPage($ARGS->{'next'});
709 $next = $next->{'url'} if ref $next;
710 InstantiateNewSession() unless _UserLoggedIn;
711 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
712 $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user);
714 if ( RT->Config->Get('WebRemoteUserAutocreate') and not _UserLoggedIn() ) {
716 # Create users on-the-fly
717 my $UserObj = RT::User->new(RT->SystemUser);
718 my ( $val, $msg ) = $UserObj->Create(
719 %{ ref RT->Config->Get('UserAutocreateDefaultsOnLogin') ? RT->Config->Get('UserAutocreateDefaultsOnLogin') : {} },
726 # now get user specific information, to better create our user.
727 my $new_user_info = RT::Interface::Web::WebRemoteUserAutocreateInfo($user);
729 # set the attributes that have been defined.
730 foreach my $attribute ( $UserObj->WritableAttributes, qw(Privileged Disabled) ) {
732 Attribute => $attribute,
734 UserInfo => $new_user_info,
735 CallbackName => 'NewUser',
736 CallbackPage => '/autohandler'
738 my $method = "Set$attribute";
739 $UserObj->$method( $new_user_info->{$attribute} ) if defined $new_user_info->{$attribute};
741 $HTML::Mason::Commands::session{'CurrentUser'}->Load($user);
743 RT->Logger->error("Couldn't auto-create user '$user' when attempting WebRemoteUser: $msg");
744 AbortExternalAuth( Error => "UserAutocreateDefaultsOnLogin" );
748 if ( _UserLoggedIn() ) {
749 $HTML::Mason::Commands::session{'WebExternallyAuthed'} = 1;
750 $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' );
751 # It is possible that we did a redirect to the login page,
752 # if the external auth allows lack of auth through with no
753 # REMOTE_USER set, instead of forcing a "permission
754 # denied" message. Honor the $next.
755 Redirect($next) if $next;
756 # Unlike AttemptPasswordAuthentication below, we do not
757 # force a redirect to / if $next is not set -- otherwise,
758 # straight-up external auth would always redirect to /
759 # when you first hit it.
761 # Couldn't auth with the REMOTE_USER provided because an RT
762 # user doesn't exist and we're configured not to create one.
763 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.");
765 Error => "NoInternalUser",
770 elsif ($logged_in_external_user) {
771 # The logged in external user was deauthed by the auth system and we
772 # should kick them out.
773 AbortExternalAuth( Error => "Deauthorized" );
775 elsif (not RT->Config->Get('WebFallbackToRTLogin')) {
776 # Abort if we don't want to fallback internally
777 AbortExternalAuth( Error => "NoRemoteUser" );
781 sub AbortExternalAuth {
783 my $error = $args{Error} ? "/Errors/WebRemoteUser/$args{Error}" : undef;
784 my $m = $HTML::Mason::Commands::m;
785 my $r = $HTML::Mason::Commands::r;
789 # Clear the decks, not that we should have partial content.
793 $m->comp($error, %args)
794 if $error and $m->comp_exists($error);
796 # Return a 403 Forbidden or we may fallback to a login page with no form
800 sub AttemptPasswordAuthentication {
802 return unless defined $ARGS->{user} && defined $ARGS->{pass};
804 my $user_obj = RT::CurrentUser->new();
805 $user_obj->Load( $ARGS->{user} );
807 my $m = $HTML::Mason::Commands::m;
809 unless ( $user_obj->id && $user_obj->IsPassword( $ARGS->{pass} ) ) {
810 $RT::Logger->error("FAILED LOGIN for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
811 $m->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' );
812 return (0, HTML::Mason::Commands::loc('Your username or password is incorrect'));
815 $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
817 # It's important to nab the next page from the session before we blow
819 my $next = RemoveNextPage($ARGS->{'next'});
820 $next = $next->{'url'} if ref $next;
822 InstantiateNewSession();
823 $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
825 $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler', RedirectTo => \$next );
827 # Really the only time we don't want to redirect here is if we were
828 # passed user and pass as query params in the URL.
832 elsif ($ARGS->{'next'}) {
833 # Invalid hash, but still wants to go somewhere, take them to /
834 Redirect(RT->Config->Get('WebURL'));
837 return (1, HTML::Mason::Commands::loc('Logged in'));
841 =head2 LoadSessionFromCookie
843 Load or setup a session cookie for the current user.
847 sub _SessionCookieName {
848 my $cookiename = "RT_SID_" . RT->Config->Get('rtname');
849 $cookiename .= "." . $ENV{'SERVER_PORT'} if $ENV{'SERVER_PORT'};
853 sub LoadSessionFromCookie {
855 my %cookies = CGI::Cookie->fetch;
856 my $cookiename = _SessionCookieName();
857 my $SessionCookie = ( $cookies{$cookiename} ? $cookies{$cookiename}->value : undef );
858 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', $SessionCookie;
859 unless ( $SessionCookie && $HTML::Mason::Commands::session{'_session_id'} eq $SessionCookie ) {
860 InstantiateNewSession();
862 if ( int RT->Config->Get('AutoLogoff') ) {
863 my $now = int( time / 60 );
864 my $last_update = $HTML::Mason::Commands::session{'_session_last_update'} || 0;
866 if ( $last_update && ( $now - $last_update - RT->Config->Get('AutoLogoff') ) > 0 ) {
867 InstantiateNewSession();
870 # save session on each request when AutoLogoff is turned on
871 $HTML::Mason::Commands::session{'_session_last_update'} = $now if $now != $last_update;
875 sub InstantiateNewSession {
876 tied(%HTML::Mason::Commands::session)->delete if tied(%HTML::Mason::Commands::session);
877 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', undef;
881 sub SendSessionCookie {
882 my $cookie = CGI::Cookie->new(
883 -name => _SessionCookieName(),
884 -value => $HTML::Mason::Commands::session{_session_id},
885 -path => RT->Config->Get('WebPath'),
886 -secure => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 ),
887 -httponly => ( RT->Config->Get('WebHttpOnlyCookies') ? 1 : 0 ),
890 $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string;
893 =head2 GetWebURLFromRequest
895 People may use different web urls instead of C<$WebURL> in config.
896 Return the web url current user is using.
900 sub GetWebURLFromRequest {
902 my $uri = URI->new( RT->Config->Get('WebURL') );
904 if ( defined $ENV{HTTPS} and $ENV{'HTTPS'} eq 'on' ) {
905 $uri->scheme('https');
908 $uri->scheme('http');
911 # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST
912 $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} );
913 $uri->port( $ENV{'SERVER_PORT'} );
914 return "$uri"; # stringify to be consistent with WebURL in config
919 This routine ells the current user's browser to redirect to URL.
920 Additionally, it unties the user's currently active session, helping to avoid
921 A bug in Apache::Session 1.81 and earlier which clobbers sessions if we try to use
922 a cached DBI statement handle twice at the same time.
927 my $redir_to = shift;
928 untie $HTML::Mason::Commands::session;
929 my $uri = URI->new($redir_to);
930 my $server_uri = URI->new( RT->Config->Get('WebURL') );
932 # Make relative URIs absolute from the server host and scheme
933 $uri->scheme($server_uri->scheme) if not defined $uri->scheme;
934 if (not defined $uri->host) {
935 $uri->host($server_uri->host);
936 $uri->port($server_uri->port);
939 # If the user is coming in via a non-canonical
940 # hostname, don't redirect them to the canonical host,
941 # it will just upset them (and invalidate their credentials)
942 # don't do this if $RT::CanonicalizeRedirectURLs is true
943 if ( !RT->Config->Get('CanonicalizeRedirectURLs')
944 && $uri->host eq $server_uri->host
945 && $uri->port eq $server_uri->port )
947 my $env_uri = URI->new(GetWebURLFromRequest());
948 $uri->scheme($env_uri->scheme);
949 $uri->host($env_uri->host);
950 $uri->port($env_uri->port);
953 # not sure why, but on some systems without this call mason doesn't
954 # set status to 302, but 200 instead and people see blank pages
955 $HTML::Mason::Commands::r->status(302);
957 # Perlbal expects a status message, but Mason's default redirect status
958 # doesn't provide one. See also rt.cpan.org #36689.
959 $HTML::Mason::Commands::m->redirect( $uri->canonical, "302 Found" );
961 $HTML::Mason::Commands::m->abort;
964 =head2 GetStaticHeaders
966 return an arrayref of Headers (currently, Cache-Control and Expires).
970 sub GetStaticHeaders {
973 my $Visibility = 'private';
974 if ( ! defined $args{Time} ) {
976 } elsif ( $args{Time} eq 'no-cache' ) {
978 } elsif ( $args{Time} eq 'forever' ) {
979 $args{Time} = 30 * 24 * 60 * 60;
980 $Visibility = 'public';
983 my $CacheControl = $args{Time}
984 ? sprintf "max-age=%d, %s", $args{Time}, $Visibility
988 my $expires = RT::Date->new(RT->SystemUser);
990 $expires->AddSeconds( $args{Time} ) if $args{Time};
993 Expires => $expires->RFC2616,
994 'Cache-Control' => $CacheControl,
998 =head2 CacheControlExpiresHeaders
1000 set both Cache-Control and Expires http headers
1004 sub CacheControlExpiresHeaders {
1005 Plack::Util::header_iter( GetStaticHeaders(@_), sub {
1006 my ( $key, $val ) = @_;
1007 $HTML::Mason::Commands::r->headers_out->{$key} = $val;
1011 =head2 StaticFileHeaders
1013 Send the browser a few headers to try to get it to (somewhat agressively)
1014 cache RT's static Javascript and CSS files.
1016 This routine could really use _accurate_ heuristics. (XXX TODO)
1020 sub StaticFileHeaders {
1021 # remove any cookie headers -- if it is cached publicly, it
1022 # shouldn't include anyone's cookie!
1023 delete $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'};
1025 # Expire things in a month.
1026 CacheControlExpiresHeaders( Time => 'forever' );
1029 =head2 ComponentPathIsSafe PATH
1031 Takes C<PATH> and returns a boolean indicating that the user-specified partial
1032 component path is safe.
1034 Currently "safe" means that the path does not start with a dot (C<.>), does
1035 not contain a slash-dot C</.>, and does not contain any nulls.
1039 sub ComponentPathIsSafe {
1042 return($path !~ m{(?:^|/)\.} and $path !~ m{\0});
1047 Takes a C<< Path => path >> and returns a boolean indicating that
1048 the path is safely within RT's control or not. The path I<must> be
1051 This function does not consult the filesystem at all; it is merely
1052 a logical sanity checking of the path. This explicitly does not handle
1053 symlinks; if you have symlinks in RT's webroot pointing outside of it,
1054 then we assume you know what you are doing.
1061 my $path = $args{Path};
1063 # Get File::Spec to clean up extra /s, ./, etc
1064 my $cleaned_up = File::Spec->canonpath($path);
1066 if (!defined($cleaned_up)) {
1067 $RT::Logger->info("Rejecting path that canonpath doesn't understand: $path");
1071 # Forbid too many ..s. We can't just sum then check because
1072 # "../foo/bar/baz" should be illegal even though it has more
1073 # downdirs than updirs. So as soon as we get a negative score
1074 # (which means "breaking out" of the top level) we reject the path.
1076 my @components = split '/', $cleaned_up;
1078 for my $component (@components) {
1079 if ($component eq '..') {
1082 $RT::Logger->info("Rejecting unsafe path: $path");
1086 elsif ($component eq '.' || $component eq '') {
1087 # these two have no effect on $score
1097 =head2 SendStaticFile
1099 Takes a File => path and a Type => Content-type
1101 If Type isn't provided and File is an image, it will
1102 figure out a sane Content-type, otherwise it will
1103 send application/octet-stream
1105 Will set caching headers using StaticFileHeaders
1109 sub SendStaticFile {
1112 my $file = $args{File};
1113 my $type = $args{Type};
1114 my $relfile = $args{RelativeFile};
1116 if (defined($relfile) && !$self->PathIsSafe(Path => $relfile)) {
1117 $HTML::Mason::Commands::r->status(400);
1118 $HTML::Mason::Commands::m->abort;
1121 $self->StaticFileHeaders();
1124 if ( $file =~ /\.(gif|png|jpe?g)$/i ) {
1126 $type =~ s/jpg/jpeg/gi;
1128 $type ||= "application/octet-stream";
1130 $HTML::Mason::Commands::r->content_type($type);
1131 open( my $fh, '<', $file ) or die "couldn't open file: $!";
1135 $HTML::Mason::Commands::m->out($_) while (<$fh>);
1136 $HTML::Mason::Commands::m->flush_buffer;
1147 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'}) {
1158 my $content = $args{Content};
1159 return '' unless $content;
1161 # Make the content have no 'weird' newlines in it
1162 $content =~ s/\r+\n/\n/g;
1164 my $return_content = $content;
1166 my $html = $args{ContentType} && $args{ContentType} eq "text/html";
1167 my $sigonly = $args{StripSignature};
1169 # massage content to easily detect if there's any real content
1170 $content =~ s/\s+//g; # yes! remove all the spaces
1172 # remove html version of spaces and newlines
1173 $content =~ s! !!g;
1174 $content =~ s!<br/?>!!g;
1177 # Filter empty content when type is text/html
1178 return '' if $html && $content !~ /\S/;
1180 # If we aren't supposed to strip the sig, just bail now.
1181 return $return_content unless $sigonly;
1183 # Find the signature
1184 my $sig = $args{'CurrentUser'}->UserObj->Signature || '';
1187 # Check for plaintext sig
1188 return '' if not $html and $content =~ /^(--)?\Q$sig\E$/;
1190 # Check for html-formatted sig; we don't use EscapeHTML here
1191 # because we want to precisely match the escapting that FCKEditor
1193 $sig =~ s/&/&/g;
1196 $sig =~ s/"/"/g;
1197 $sig =~ s/'/'/g;
1198 return '' if $html and $content =~ m{^(?:<p>)?(--)?\Q$sig\E(?:</p>)?$}s;
1201 return $return_content;
1207 # Later in the code we use
1208 # $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %ARGS );
1209 # instead of $m->call_next to avoid problems with UTF8 keys in
1210 # arguments. Specifically, the call_next method pass through
1211 # original arguments, which are still the encoded bytes, not
1212 # characters. "{ base_comp => $m->request_comp }" is copied from
1213 # mason's source to get the same results as we get from call_next
1214 # method; this feature is not documented.
1217 # if they've passed multiple values, they'll be an array. if they've
1218 # passed just one, a scalar whatever they are, mark them as utf8
1221 ? Encode::decode( 'UTF-8', $_, Encode::FB_PERLQQ )
1222 : ( $type eq 'ARRAY' )
1223 ? [ map { ref($_) ? $_ : Encode::decode( 'UTF-8', $_, Encode::FB_PERLQQ ) } @$_ ]
1224 : ( $type eq 'HASH' )
1225 ? { map { ref($_) ? $_ : Encode::decode( 'UTF-8', $_, Encode::FB_PERLQQ ) } %$_ }
1230 sub PreprocessTimeUpdates {
1233 # This code canonicalizes time inputs in hours into minutes
1234 foreach my $field ( keys %$ARGS ) {
1235 next unless $field =~ /^(.*)-TimeUnits$/i && $ARGS->{$1};
1237 $ARGS->{$local} =~ s{\b (?: (\d+) \s+ )? (\d+)/(\d+) \b}
1238 {($1 || 0) + $3 ? $2 / $3 : 0}xe;
1239 if ( $ARGS->{$field} && $ARGS->{$field} =~ /hours/i ) {
1240 $ARGS->{$local} *= 60;
1242 delete $ARGS->{$field};
1247 sub MaybeEnableSQLStatementLog {
1249 my $log_sql_statements = RT->Config->Get('StatementLog');
1251 if ($log_sql_statements) {
1252 $RT::Handle->ClearSQLStatementLog;
1253 $RT::Handle->LogSQLStatements(1);
1258 sub LogRecordedSQLStatements {
1261 my $log_sql_statements = RT->Config->Get('StatementLog');
1263 return unless ($log_sql_statements);
1265 my @log = $RT::Handle->SQLStatementLog;
1266 $RT::Handle->ClearSQLStatementLog;
1268 $RT::Handle->AddRequestToHistory({
1269 %{ $args{RequestData} },
1273 for my $stmt (@log) {
1274 my ( $time, $sql, $bind, $duration ) = @{$stmt};
1284 level => $log_sql_statements,
1286 . sprintf( "%.6f", $duration )
1288 . ( @bind ? " [ bound values: @{[map{ defined $_ ? qq|'$_'| : 'undef'} @bind]} ]" : "" )
1294 my $_has_validated_web_config = 0;
1295 sub ValidateWebConfig {
1298 # do this once per server instance, not once per request
1299 return if $_has_validated_web_config;
1300 $_has_validated_web_config = 1;
1302 my $port = $ENV{SERVER_PORT};
1303 my $host = $ENV{HTTP_X_FORWARDED_HOST} || $ENV{HTTP_X_FORWARDED_SERVER}
1304 || $ENV{HTTP_HOST} || $ENV{SERVER_NAME};
1305 ($host, $port) = ($1, $2) if $host =~ /^(.*?):(\d+)$/;
1307 if ( $port != RT->Config->Get('WebPort') and not $ENV{'rt.explicit_port'}) {
1308 $RT::Logger->warn("The requested port ($port) does NOT match the configured WebPort ($RT::WebPort). "
1309 ."Perhaps you should Set(\$WebPort, $port); in RT_SiteConfig.pm, "
1310 ."otherwise your internal links may be broken.");
1313 if ( $host ne RT->Config->Get('WebDomain') ) {
1314 $RT::Logger->warn("The requested host ($host) does NOT match the configured WebDomain ($RT::WebDomain). "
1315 ."Perhaps you should Set(\$WebDomain, '$host'); in RT_SiteConfig.pm, "
1316 ."otherwise your internal links may be broken.");
1319 # Unfortunately, there is no reliable way to get the _path_ that was
1320 # requested at the proxy level; simply disable this warning if we're
1321 # proxied and there's a mismatch.
1322 my $proxied = $ENV{HTTP_X_FORWARDED_HOST} || $ENV{HTTP_X_FORWARDED_SERVER};
1323 if ($ENV{SCRIPT_NAME} ne RT->Config->Get('WebPath') and not $proxied) {
1324 $RT::Logger->warn("The requested path ($ENV{SCRIPT_NAME}) does NOT match the configured WebPath ($RT::WebPath). "
1325 ."Perhaps you should Set(\$WebPath, '$ENV{SCRIPT_NAME}'); in RT_SiteConfig.pm, "
1326 ."otherwise your internal links may be broken.");
1330 sub ComponentRoots {
1332 my %args = ( Names => 0, @_ );
1334 if (defined $HTML::Mason::Commands::m) {
1335 @roots = $HTML::Mason::Commands::m->interp->comp_root_array;
1338 [ local => $RT::MasonLocalComponentRoot ],
1339 (map {[ "plugin-".$_->Name => $_->ComponentRoot ]} @{RT->Plugins}),
1340 [ standard => $RT::MasonComponentRoot ]
1343 @roots = map { $_->[1] } @roots unless $args{Names};
1350 $RT::LocalStaticPath,
1351 (map { $_->StaticDir } @{RT->Plugins}),
1354 return grep { $_ and -d $_ } @static;
1357 our %is_whitelisted_component = (
1358 # The RSS feed embeds an auth token in the path, but query
1359 # information for the search. Because it's a straight-up read, in
1360 # addition to embedding its own auth, it's fine.
1361 '/NoAuth/rss/dhandler' => 1,
1363 # While these can be used for denial-of-service against RT
1364 # (construct a very inefficient query and trick lots of users into
1365 # running them against RT) it's incredibly useful to be able to link
1366 # to a search result (or chart) or bookmark a result page.
1367 '/Search/Results.html' => 1,
1368 '/Search/Simple.html' => 1,
1369 '/m/tickets/search' => 1,
1370 '/Search/Chart.html' => 1,
1371 '/User/Search.html' => 1,
1373 # This page takes Attachment and Transaction argument to figure
1374 # out what to show, but it's read only and will deny information if you
1375 # don't have ShowOutgoingEmail.
1376 '/Ticket/ShowEmailRecord.html' => 1,
1379 # Components which are blacklisted from automatic, argument-based whitelisting.
1380 # These pages are not idempotent when called with just an id.
1381 our %is_blacklisted_component = (
1382 # Takes only id and toggles bookmark state
1383 '/Helpers/Toggle/TicketBookmark' => 1,
1386 sub IsCompCSRFWhitelisted {
1390 return 1 if $is_whitelisted_component{$comp};
1392 my %args = %{ $ARGS };
1394 # If the user specifies a *correct* user and pass then they are
1395 # golden. This acts on the presumption that external forms may
1396 # hardcode a username and password -- if a malicious attacker knew
1397 # both already, CSRF is the least of your problems.
1398 my $AllowLoginCSRF = not RT->Config->Get('RestrictReferrerLogin');
1399 if ($AllowLoginCSRF and defined($args{user}) and defined($args{pass})) {
1400 my $user_obj = RT::CurrentUser->new();
1401 $user_obj->Load($args{user});
1402 return 1 if $user_obj->id && $user_obj->IsPassword($args{pass});
1408 # Some pages aren't idempotent even with safe args like id; blacklist
1409 # them from the automatic whitelisting below.
1410 return 0 if $is_blacklisted_component{$comp};
1412 # Eliminate arguments that do not indicate an effectful request.
1413 # For example, "id" is acceptable because that is how RT retrieves a
1417 # If they have a results= from MaybeRedirectForResults, that's also fine.
1418 delete $args{results};
1420 # The homepage refresh, which uses the Refresh header, doesn't send
1421 # a referer in most browsers; whitelist the one parameter it reloads
1422 # with, HomeRefreshInterval, which is safe
1423 delete $args{HomeRefreshInterval};
1425 # The NotMobile flag is fine for any page; it's only used to toggle a flag
1426 # in the session related to which interface you get.
1427 delete $args{NotMobile};
1429 # If there are no arguments, then it's likely to be an idempotent
1430 # request, which are not susceptible to CSRF
1436 sub IsRefererCSRFWhitelisted {
1437 my $referer = _NormalizeHost(shift);
1438 my $base_url = _NormalizeHost(RT->Config->Get('WebBaseURL'));
1439 $base_url = $base_url->host_port;
1442 for my $config ( $base_url, RT->Config->Get('ReferrerWhitelist') ) {
1443 push @$configs,$config;
1445 my $host_port = $referer->host_port;
1446 if ($config =~ /\*/) {
1447 # Turn a literal * into a domain component or partial component match.
1448 # Refer to http://tools.ietf.org/html/rfc2818#page-5
1449 my $regex = join "[a-zA-Z0-9\-]*",
1450 map { quotemeta($_) }
1451 split /\*/, $config;
1453 return 1 if $host_port =~ /^$regex$/i;
1455 return 1 if $host_port eq $config;
1459 return (0,$referer,$configs);
1462 =head3 _NormalizeHost
1464 Takes a URI and creates a URI object that's been normalized
1465 to handle common problems such as localhost vs 127.0.0.1
1469 sub _NormalizeHost {
1471 my $uri= URI->new(shift);
1472 $uri->host('127.0.0.1') if $uri->host eq 'localhost';
1478 sub IsPossibleCSRF {
1481 # If first request on this session is to a REST endpoint, then
1482 # whitelist the REST endpoints -- and explicitly deny non-REST
1483 # endpoints. We do this because using a REST cookie in a browser
1484 # would open the user to CSRF attacks to the REST endpoints.
1485 my $path = $HTML::Mason::Commands::r->path_info;
1486 $HTML::Mason::Commands::session{'REST'} = $path =~ m{^/+REST/\d+\.\d+(/|$)}
1487 unless defined $HTML::Mason::Commands::session{'REST'};
1489 if ($HTML::Mason::Commands::session{'REST'}) {
1490 return 0 if $path =~ m{^/+REST/\d+\.\d+(/|$)};
1492 This login session belongs to a REST client, and cannot be used to
1493 access non-REST interfaces of RT for security reasons.
1495 my $details = <<EOT;
1496 Please log out and back in to obtain a session for normal browsing. If
1497 you understand the security implications, disabling RT's CSRF protection
1498 will remove this restriction.
1501 HTML::Mason::Commands::Abort( $why, Details => $details );
1504 return 0 if IsCompCSRFWhitelisted(
1505 $HTML::Mason::Commands::m->request_comp->path,
1509 # if there is no Referer header then assume the worst
1511 "your browser did not supply a Referrer header", # loc
1512 ) if !$ENV{HTTP_REFERER};
1514 my ($whitelisted, $browser, $configs) = IsRefererCSRFWhitelisted($ENV{HTTP_REFERER});
1515 return 0 if $whitelisted;
1517 if ( @$configs > 1 ) {
1519 "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2]) or whitelisted hosts ([_3])", # loc
1520 $browser->host_port,
1522 join(', ', @$configs) );
1526 "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2])", # loc
1527 $browser->host_port,
1531 sub ExpandCSRFToken {
1534 my $token = delete $ARGS->{CSRF_Token};
1535 return unless $token;
1537 my $data = $HTML::Mason::Commands::session{'CSRF'}{$token};
1538 return unless $data;
1539 return unless $data->{path} eq $HTML::Mason::Commands::r->path_info;
1541 my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj;
1542 return unless $user->ValidateAuthString( $data->{auth}, $token );
1544 %{$ARGS} = %{$data->{args}};
1545 $HTML::Mason::Commands::DECODED_ARGS = $ARGS;
1547 # We explicitly stored file attachments with the request, but not in
1548 # the session yet, as that would itself be an attack. Put them into
1549 # the session now, so they'll be visible.
1550 if ($data->{attach}) {
1551 my $filename = $data->{attach}{filename};
1552 my $mime = $data->{attach}{mime};
1553 $HTML::Mason::Commands::session{'Attachments'}{$ARGS->{'Token'}||''}{$filename}
1560 sub StoreRequestToken {
1563 my $token = Digest::MD5::md5_hex(time . {} . $$ . rand(1024));
1564 my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj;
1566 auth => $user->GenerateAuthString( $token ),
1567 path => $HTML::Mason::Commands::r->path_info,
1570 if ($ARGS->{Attach}) {
1571 my $attachment = HTML::Mason::Commands::MakeMIMEEntity( AttachmentFieldName => 'Attach' );
1572 my $file_path = delete $ARGS->{'Attach'};
1574 # This needs to be decoded because the value is a reference;
1575 # hence it was not decoded along with all of the standard
1576 # arguments in DecodeARGS
1578 filename => Encode::decode("UTF-8", "$file_path"),
1579 mime => $attachment,
1583 $HTML::Mason::Commands::session{'CSRF'}->{$token} = $data;
1584 $HTML::Mason::Commands::session{'i'}++;
1588 sub MaybeShowInterstitialCSRFPage {
1591 return unless RT->Config->Get('RestrictReferrer');
1593 # Deal with the form token provided by the interstitial, which lets
1594 # browsers which never set referer headers still use RT, if
1595 # painfully. This blows values into ARGS
1596 return if ExpandCSRFToken($ARGS);
1598 my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS);
1599 return if !$is_csrf;
1601 $RT::Logger->notice("Possible CSRF: ".RT::CurrentUser->new->loc($msg, @loc));
1603 my $token = StoreRequestToken($ARGS);
1604 $HTML::Mason::Commands::m->comp(
1606 OriginalURL => RT->Config->Get('WebPath') . $HTML::Mason::Commands::r->path_info,
1607 Reason => HTML::Mason::Commands::loc( $msg, @loc ),
1610 # Calls abort, never gets here
1613 our @POTENTIAL_PAGE_ACTIONS = (
1614 qr'/Ticket/Create.html' => "create a ticket", # loc
1615 qr'/Ticket/' => "update a ticket", # loc
1616 qr'/Admin/' => "modify RT's configuration", # loc
1617 qr'/Approval/' => "update an approval", # loc
1618 qr'/Articles/' => "update an article", # loc
1619 qr'/Dashboards/' => "modify a dashboard", # loc
1620 qr'/m/ticket/' => "update a ticket", # loc
1621 qr'Prefs' => "modify your preferences", # loc
1622 qr'/Search/' => "modify or access a search", # loc
1623 qr'/SelfService/Create' => "create a ticket", # loc
1624 qr'/SelfService/' => "update a ticket", # loc
1627 sub PotentialPageAction {
1629 my @potentials = @POTENTIAL_PAGE_ACTIONS;
1630 while (my ($pattern, $result) = splice @potentials, 0, 2) {
1631 return HTML::Mason::Commands::loc($result)
1632 if $page =~ $pattern;
1637 =head2 RewriteInlineImages PARAMHASH
1639 Turns C<< <img src="cid:..."> >> elements in HTML into working images pointing
1640 back to RT's stored copy.
1642 Takes the following parameters:
1648 Scalar ref of the HTML content to rewrite. Modified in place to support the
1649 most common use-case.
1653 The L<RT::Attachment> object from which the Content originates.
1655 =item Related (optional)
1657 Array ref of related L<RT::Attachment> objects to use for C<Content-ID> matching.
1659 Defaults to the result of the C<Siblings> method on the passed Attachment.
1661 =item AttachmentPath (optional)
1663 The base path to use when rewriting C<src> attributes.
1665 Defaults to C< $WebPath/Ticket/Attachment >
1669 In scalar context, returns the number of elements rewritten.
1671 In list content, returns the attachments IDs referred to by the rewritten <img>
1672 elements, in the order found. There may be duplicates.
1676 sub RewriteInlineImages {
1679 Attachment => undef,
1681 AttachmentPath => RT->Config->Get('WebPath')."/Ticket/Attachment",
1685 return unless defined $args{Content}
1686 and ref $args{Content} eq 'SCALAR'
1687 and defined $args{Attachment};
1689 my $related_part = $args{Attachment}->Closest("multipart/related")
1692 $args{Related} ||= $related_part->Children->ItemsArrayRef;
1693 return unless @{$args{Related}};
1695 my $content = $args{'Content'};
1698 require HTML::RewriteAttributes::Resources;
1699 $$content = HTML::RewriteAttributes::Resources->rewrite($$content, sub {
1702 return $cid unless lc $meta{tag} eq 'img'
1703 and lc $meta{attr} eq 'src'
1704 and $cid =~ s/^cid://i;
1706 for my $attach (@{$args{Related}}) {
1707 if (($attach->GetHeader('Content-ID') || '') =~ /^(<)?\Q$cid\E(?(1)>)$/) {
1708 push @rewritten, $attach->Id;
1709 return "$args{AttachmentPath}/" . $attach->TransactionId . '/' . $attach->Id;
1713 # No attachments means this is a bogus CID. Just pass it through.
1714 RT->Logger->debug(qq[Found bogus inline image src="cid:$cid"]);
1720 =head2 GetCustomFieldInputName(CustomField => $cf_object, Object => $object, Grouping => $grouping_name)
1722 Returns the standard custom field input name; this is complementary to
1723 L</_ParseObjectCustomFieldArgs>. Takes the following arguments:
1727 =item CustomField => I<L<RT::CustomField> object>
1731 =item Object => I<object>
1733 The object that the custom field is applied to; optional. If omitted,
1734 defaults to a new object of the appropriate class for the custom field.
1736 =item Grouping => I<CF grouping>
1738 The grouping that the custom field is being rendered in. Groupings
1739 allow a custom field to appear in more than one location per form.
1745 sub GetCustomFieldInputName {
1747 CustomField => undef,
1753 my $name = GetCustomFieldInputNamePrefix(%args);
1755 if ( $args{CustomField}->Type eq 'Select' ) {
1756 if ( $args{CustomField}->RenderType eq 'List' and $args{CustomField}->SingleValue ) {
1763 elsif ( $args{CustomField}->Type =~ /^(?:Binary|Image)$/ ) {
1766 elsif ( $args{CustomField}->Type =~ /^(?:Date|DateTime|Text|Wikitext)$/ ) {
1770 if ( $args{CustomField}->SingleValue ) {
1781 =head2 GetCustomFieldInputNamePrefix(CustomField => $cf_object, Object => $object, Grouping => $grouping_name)
1783 Returns the standard custom field input name prefix(without "Value" or alike suffix)
1787 sub GetCustomFieldInputNamePrefix {
1789 CustomField => undef,
1795 my $prefix = join '-', 'Object', ref( $args{Object} ) || $args{CustomField}->ObjectTypeFromLookupType,
1796 ( $args{Object} && $args{Object}->id ? $args{Object}->id : '' ),
1797 'CustomField' . ( $args{Grouping} ? ":$args{Grouping}" : '' ),
1798 $args{CustomField}->id, '';
1803 package HTML::Mason::Commands;
1805 use vars qw/$r $m %session/;
1807 use Scalar::Util qw(blessed);
1810 return $HTML::Mason::Commands::m->notes('menu');
1814 return $HTML::Mason::Commands::m->notes('page-menu');
1818 return $HTML::Mason::Commands::m->notes('page-widgets');
1822 my %args = (toplevel => 1, parent_id => '', depth => 0, @_);
1823 return unless $args{'menu'};
1825 my ($menu, $depth, $toplevel, $id, $parent_id)
1826 = @args{qw(menu depth toplevel id parent_id)};
1828 my $interp = $m->interp;
1829 my $web_path = RT->Config->Get('WebPath');
1832 $res .= ' ' x $depth;
1834 $res .= ' id="'. $interp->apply_escapes($id, 'h') .'"'
1836 $res .= ' class="toplevel"' if $toplevel;
1839 for my $child ($menu->children) {
1840 $res .= ' 'x ($depth+1);
1842 my $item_id = lc(($parent_id? "$parent_id-" : "") .$child->key);
1843 $item_id =~ s/\s/-/g;
1844 my $eitem_id = $interp->apply_escapes($item_id, 'h');
1845 $res .= qq{<li id="li-$eitem_id"};
1848 push @classes, 'has-children' if $child->has_children;
1849 push @classes, 'active' if $child->active;
1850 $res .= ' class="'. join( ' ', @classes ) .'"'
1855 if ( my $tmp = $child->raw_html ) {
1858 $res .= qq{<a id="$eitem_id" class="menu-item};
1859 if ( $tmp = $child->class ) {
1860 $res .= ' '. $interp->apply_escapes($tmp, 'h');
1864 my $path = $child->path;
1865 my $url = (not $path or $path =~ m{^\w+:/}) ? $path : $web_path . $path;
1866 $res .= ' href="'. $interp->apply_escapes($url, 'h') .'"'
1869 if ( $tmp = $child->target ) {
1870 $res .= ' target="'. $interp->apply_escapes($tmp, 'h') .'"'
1873 if ($child->attributes) {
1874 for my $key (keys %{$child->attributes}) {
1875 my ($name, $value) = map { $interp->apply_escapes($_, 'h') }
1876 $key, $child->attributes->{$key};
1877 $res .= " $name=\"$value\"";
1882 if ( $child->escape_title ) {
1883 $res .= $interp->apply_escapes($child->title, 'h');
1885 $res .= $child->title;
1890 if ( $child->has_children ) {
1895 parent_id => $item_id,
1900 $res .= ' ' x ($depth+1);
1904 $res .= ' ' x $depth;
1906 return $res if $args{'return'};
1914 loc is a nice clean global routine which calls $session{'CurrentUser'}->loc()
1915 with whatever it's called with. If there is no $session{'CurrentUser'},
1916 it creates a temporary user, so we have something to get a localisation handle
1923 if ( $session{'CurrentUser'}
1924 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1926 return ( $session{'CurrentUser'}->loc(@_) );
1929 RT::CurrentUser->new();
1933 return ( $u->loc(@_) );
1936 # pathetic case -- SystemUser is gone.
1943 =head2 loc_fuzzy STRING
1945 loc_fuzzy is for handling localizations of messages that may already
1946 contain interpolated variables, typically returned from libraries
1947 outside RT's control. It takes the message string and extracts the
1948 variable array automatically by matching against the candidate entries
1949 inside the lexicon file.
1956 if ( $session{'CurrentUser'}
1957 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1959 return ( $session{'CurrentUser'}->loc_fuzzy($msg) );
1961 my $u = RT::CurrentUser->new( RT->SystemUser->Id );
1962 return ( $u->loc_fuzzy($msg) );
1967 # Error - calls Error and aborts
1972 if ( $session{'ErrorDocument'}
1973 && $session{'ErrorDocumentType'} )
1975 $r->content_type( $session{'ErrorDocumentType'} );
1976 $m->comp( $session{'ErrorDocument'}, Why => $why, %args );
1979 $m->comp( "/Elements/Error", Why => $why, %args );
1984 sub MaybeRedirectForResults {
1986 Path => $HTML::Mason::Commands::m->request_comp->path,
1993 my $has_actions = $args{'Actions'} && grep( defined, @{ $args{'Actions'} } );
1994 return unless $has_actions || $args{'Force'};
1996 my %arguments = %{ $args{'Arguments'} };
1998 if ( $has_actions ) {
1999 my $key = Digest::MD5::md5_hex( rand(1024) );
2000 push @{ $session{"Actions"}{ $key } ||= [] }, @{ $args{'Actions'} };
2002 $arguments{'results'} = $key;
2005 $args{'Path'} =~ s!^/+!!;
2006 my $url = RT->Config->Get('WebURL') . $args{Path};
2008 if ( keys %arguments ) {
2009 $url .= '?'. $m->comp( '/Elements/QueryString', %arguments );
2011 if ( $args{'Anchor'} ) {
2012 $url .= "#". $args{'Anchor'};
2014 return RT::Interface::Web::Redirect($url);
2017 =head2 MaybeRedirectToApproval Path => 'path', Whitelist => REGEX, ARGSRef => HASHREF
2019 If the ticket specified by C<< $ARGSRef->{id} >> is an approval ticket,
2020 redirect to the approvals display page, preserving any arguments.
2022 C<Path>s matching C<Whitelist> are let through.
2024 This is a no-op if the C<ForceApprovalsView> option isn't enabled.
2028 sub MaybeRedirectToApproval {
2030 Path => $HTML::Mason::Commands::m->request_comp->path,
2036 return unless $ENV{REQUEST_METHOD} eq 'GET';
2038 my $id = $args{ARGSRef}->{id};
2041 and RT->Config->Get('ForceApprovalsView')
2042 and not $args{Path} =~ /$args{Whitelist}/)
2044 my $ticket = RT::Ticket->new( $session{'CurrentUser'} );
2047 if ($ticket and $ticket->id and lc($ticket->Type) eq 'approval') {
2048 MaybeRedirectForResults(
2049 Path => "/Approvals/Display.html",
2051 Anchor => $args{ARGSRef}->{Anchor},
2052 Arguments => $args{ARGSRef},
2058 =head2 CreateTicket ARGS
2060 Create a new ticket, using Mason's %ARGS. returns @results.
2069 my $Ticket = RT::Ticket->new( $session{'CurrentUser'} );
2071 my $Queue = RT::Queue->new( $session{'CurrentUser'} );
2072 unless ( $Queue->Load( $ARGS{'Queue'} ) ) {
2073 Abort('Queue not found');
2076 unless ( $Queue->CurrentUserHasRight('CreateTicket') ) {
2077 Abort('You have no permission to create tickets in that queue.');
2081 if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) {
2082 $due = RT::Date->new( $session{'CurrentUser'} );
2083 $due->Set( Format => 'unknown', Value => $ARGS{'Due'} );
2086 if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) {
2087 $starts = RT::Date->new( $session{'CurrentUser'} );
2088 $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} );
2091 my $sigless = RT::Interface::Web::StripContent(
2092 Content => $ARGS{Content},
2093 ContentType => $ARGS{ContentType},
2094 StripSignature => 1,
2095 CurrentUser => $session{'CurrentUser'},
2098 my $MIMEObj = MakeMIMEEntity(
2099 Subject => $ARGS{'Subject'},
2100 From => $ARGS{'From'},
2103 Type => $ARGS{'ContentType'},
2104 Interface => RT::Interface::Web::MobileClient() ? 'Mobile' : 'Web',
2108 if ( my $tmp = $session{'Attachments'}{ $ARGS{'Token'} || '' } ) {
2109 push @attachments, grep $_, map $tmp->{$_}, sort keys %$tmp;
2111 delete $session{'Attachments'}{ $ARGS{'Token'} || '' }
2112 unless $ARGS{'KeepAttachments'};
2113 $session{'Attachments'} = $session{'Attachments'}
2116 if ( $ARGS{'Attachments'} ) {
2117 push @attachments, grep $_, map $ARGS{Attachments}->{$_}, sort keys %{ $ARGS{'Attachments'} };
2119 if ( @attachments ) {
2120 $MIMEObj->make_multipart;
2121 $MIMEObj->add_part( $_ ) foreach @attachments;
2124 for my $argument (qw(Encrypt Sign)) {
2125 if ( defined $ARGS{ $argument } ) {
2126 $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 );
2131 Type => $ARGS{'Type'} || 'ticket',
2132 Queue => $ARGS{'Queue'},
2133 Owner => $ARGS{'Owner'},
2136 Requestor => $ARGS{'Requestors'},
2138 AdminCc => $ARGS{'AdminCc'},
2139 InitialPriority => $ARGS{'InitialPriority'},
2140 FinalPriority => $ARGS{'FinalPriority'},
2141 TimeLeft => $ARGS{'TimeLeft'},
2142 TimeEstimated => $ARGS{'TimeEstimated'},
2143 TimeWorked => $ARGS{'TimeWorked'},
2144 Subject => $ARGS{'Subject'},
2145 Status => $ARGS{'Status'},
2146 Due => $due ? $due->ISO : undef,
2147 Starts => $starts ? $starts->ISO : undef,
2148 MIMEObj => $MIMEObj,
2149 TransSquelchMailTo => $ARGS{'TransSquelchMailTo'},
2152 if ($ARGS{'DryRun'}) {
2153 $create_args{DryRun} = 1;
2154 $create_args{Owner} ||= $RT::Nobody->Id;
2155 $create_args{Requestor} ||= $session{CurrentUser}->EmailAddress;
2156 $create_args{Subject} ||= '';
2157 $create_args{Status} ||= $Queue->Lifecycle->DefaultOnCreate,
2160 foreach my $type (qw(Requestor Cc AdminCc)) {
2161 push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$type} )
2162 if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] };
2164 push @{$create_args{TransSquelchMailTo}}, @txn_squelch;
2167 if ( $ARGS{'AttachTickets'} ) {
2168 require RT::Action::SendEmail;
2169 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
2170 ref $ARGS{'AttachTickets'}
2171 ? @{ $ARGS{'AttachTickets'} }
2172 : ( $ARGS{'AttachTickets'} ) );
2175 my %cfs = ProcessObjectCustomFieldUpdatesForCreate(
2177 ContextObject => $Queue,
2180 my %links = ProcessLinksForCreate( ARGSRef => \%ARGS );
2182 my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args, %links, %cfs);
2183 return $Trans if $ARGS{DryRun};
2189 push( @Actions, split( "\n", $ErrMsg ) );
2190 unless ( $Ticket->CurrentUserHasRight('ShowTicket') ) {
2191 Abort( "No permission to view newly created ticket #" . $Ticket->id . "." );
2193 return ( $Ticket, @Actions );
2199 =head2 LoadTicket id
2201 Takes a ticket id as its only variable. if it's handed an array, it takes
2204 Returns an RT::Ticket object as the current user.
2211 if ( ref($id) eq "ARRAY" ) {
2216 Abort("No ticket specified");
2219 my $Ticket = RT::Ticket->new( $session{'CurrentUser'} );
2221 unless ( $Ticket->id ) {
2222 Abort("Could not load ticket $id");
2229 =head2 ProcessUpdateMessage
2231 Takes paramhash with fields ARGSRef, TicketObj and SkipSignatureOnly.
2233 Don't write message if it only contains current user's signature and
2234 SkipSignatureOnly argument is true. Function anyway adds attachments
2235 and updates time worked field even if skips message. The default value
2240 sub ProcessUpdateMessage {
2245 SkipSignatureOnly => 1,
2250 if ( my $tmp = $session{'Attachments'}{ $args{'ARGSRef'}{'Token'} || '' } ) {
2251 push @attachments, grep $_, map $tmp->{$_}, sort keys %$tmp;
2253 delete $session{'Attachments'}{ $args{'ARGSRef'}{'Token'} || '' }
2254 unless $args{'KeepAttachments'};
2255 $session{'Attachments'} = $session{'Attachments'}
2258 if ( $args{ARGSRef}{'UpdateAttachments'} ) {
2259 push @attachments, grep $_, map $args{ARGSRef}->{UpdateAttachments}{$_},
2260 sort keys %{ $args{ARGSRef}->{'UpdateAttachments'} };
2263 # Strip the signature
2264 $args{ARGSRef}->{UpdateContent} = RT::Interface::Web::StripContent(
2265 Content => $args{ARGSRef}->{UpdateContent},
2266 ContentType => $args{ARGSRef}->{UpdateContentType},
2267 StripSignature => $args{SkipSignatureOnly},
2268 CurrentUser => $args{'TicketObj'}->CurrentUser,
2271 # If, after stripping the signature, we have no message, move the
2272 # UpdateTimeWorked into adjusted TimeWorked, so that a later
2273 # ProcessBasics can deal -- then bail out.
2274 if ( not @attachments
2275 and not length $args{ARGSRef}->{'UpdateContent'} )
2277 if ( $args{ARGSRef}->{'UpdateTimeWorked'} ) {
2278 $args{ARGSRef}->{TimeWorked} = $args{TicketObj}->TimeWorked + delete $args{ARGSRef}->{'UpdateTimeWorked'};
2283 if ( ($args{ARGSRef}->{'UpdateSubject'}||'') eq ($args{'TicketObj'}->Subject || '') ) {
2284 $args{ARGSRef}->{'UpdateSubject'} = undef;
2287 my $Message = MakeMIMEEntity(
2288 Subject => $args{ARGSRef}->{'UpdateSubject'},
2289 Body => $args{ARGSRef}->{'UpdateContent'},
2290 Type => $args{ARGSRef}->{'UpdateContentType'},
2291 Interface => RT::Interface::Web::MobileClient() ? 'Mobile' : 'Web',
2294 $Message->head->replace( 'Message-ID' => Encode::encode( "UTF-8",
2295 RT::Interface::Email::GenMessageId( Ticket => $args{'TicketObj'} )
2297 my $old_txn = RT::Transaction->new( $session{'CurrentUser'} );
2298 if ( $args{ARGSRef}->{'QuoteTransaction'} ) {
2299 $old_txn->Load( $args{ARGSRef}->{'QuoteTransaction'} );
2301 $old_txn = $args{TicketObj}->Transactions->First();
2304 if ( my $msg = $old_txn->Message->First ) {
2305 RT::Interface::Email::SetInReplyTo(
2306 Message => $Message,
2308 Ticket => $args{'TicketObj'},
2312 if ( @attachments ) {
2313 $Message->make_multipart;
2314 $Message->add_part( $_ ) foreach @attachments;
2317 if ( $args{ARGSRef}->{'AttachTickets'} ) {
2318 require RT::Action::SendEmail;
2319 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
2320 ref $args{ARGSRef}->{'AttachTickets'}
2321 ? @{ $args{ARGSRef}->{'AttachTickets'} }
2322 : ( $args{ARGSRef}->{'AttachTickets'} ) );
2325 my %message_args = (
2326 Sign => $args{ARGSRef}->{'Sign'},
2327 Encrypt => $args{ARGSRef}->{'Encrypt'},
2328 MIMEObj => $Message,
2329 TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'}
2332 _ProcessUpdateMessageRecipients(
2333 MessageArgs => \%message_args,
2338 if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) {
2339 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment(%message_args);
2340 push( @results, $Description );
2341 $Object->UpdateCustomFields( %{ $args{ARGSRef} } ) if $Object;
2342 } elsif ( $args{ARGSRef}->{'UpdateType'} eq 'response' ) {
2343 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Correspond(%message_args);
2344 push( @results, $Description );
2345 $Object->UpdateCustomFields( %{ $args{ARGSRef} } ) if $Object;
2348 loc("Update type was neither correspondence nor comment.") . " " . loc("Update not recorded.") );
2353 sub _ProcessUpdateMessageRecipients {
2357 MessageArgs => undef,
2361 my $bcc = $args{ARGSRef}->{'UpdateBcc'};
2362 my $cc = $args{ARGSRef}->{'UpdateCc'};
2364 my $message_args = $args{MessageArgs};
2366 $message_args->{CcMessageTo} = $cc;
2367 $message_args->{BccMessageTo} = $bcc;
2370 foreach my $type (qw(Cc AdminCc)) {
2371 if (grep $_ eq $type || $_ eq ( $type . 's' ), @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
2372 push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{$type} );
2373 push @txn_squelch, $args{TicketObj}->$type->MemberEmailAddresses;
2374 push @txn_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses;
2377 if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
2378 push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{Requestor} );
2379 push @txn_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses;
2382 push @txn_squelch, @{$args{ARGSRef}{SquelchMailTo}} if $args{ARGSRef}{SquelchMailTo};
2383 $message_args->{SquelchMailTo} = \@txn_squelch
2386 unless ( $args{'ARGSRef'}->{'UpdateIgnoreAddressCheckboxes'} ) {
2387 foreach my $key ( keys %{ $args{ARGSRef} } ) {
2388 next unless $key =~ /^Update(Cc|Bcc)-(.*)$/;
2390 my $var = ucfirst($1) . 'MessageTo';
2392 if ( $message_args->{$var} ) {
2393 $message_args->{$var} .= ", $value";
2395 $message_args->{$var} = $value;
2401 sub ProcessAttachments {
2408 my $token = $args{'ARGSRef'}{'Token'}
2409 ||= $args{'Token'} ||= Digest::MD5::md5_hex( rand(1024) );
2411 my $update_session = 0;
2413 # deal with deleting uploaded attachments
2414 if ( my $del = $args{'ARGSRef'}{'DeleteAttach'} ) {
2415 delete $session{'Attachments'}{ $token }{ $_ }
2416 foreach ref $del? @$del : ($del);
2418 $update_session = 1;
2421 # store the uploaded attachment in session
2422 my $new = $args{'ARGSRef'}{'Attach'};
2423 if ( defined $new && length $new ) {
2424 my $attachment = MakeMIMEEntity(
2425 AttachmentFieldName => 'Attach'
2428 # This needs to be decoded because the value is a reference;
2429 # hence it was not decoded along with all of the standard
2430 # arguments in DecodeARGS
2431 my $file_path = Encode::decode( "UTF-8", "$new");
2432 $session{'Attachments'}{ $token }{ $file_path } = $attachment;
2434 $update_session = 1;
2436 $session{'Attachments'} = $session{'Attachments'} if $update_session;
2440 =head2 MakeMIMEEntity PARAMHASH
2442 Takes a paramhash Subject, Body and AttachmentFieldName.
2444 Also takes Form, Cc and Type as optional paramhash keys.
2446 Returns a MIME::Entity.
2450 sub MakeMIMEEntity {
2452 #TODO document what else this takes.
2458 AttachmentFieldName => undef,
2463 my $Message = MIME::Entity->build(
2464 Type => 'multipart/mixed',
2465 "Message-Id" => Encode::encode( "UTF-8", RT::Interface::Email::GenMessageId ),
2466 "X-RT-Interface" => $args{Interface},
2467 map { $_ => Encode::encode( "UTF-8", $args{ $_} ) }
2468 grep defined $args{$_}, qw(Subject From Cc)
2471 if ( defined $args{'Body'} && length $args{'Body'} ) {
2473 # Make the update content have no 'weird' newlines in it
2474 $args{'Body'} =~ s/\r\n/\n/gs;
2477 Type => $args{'Type'} || 'text/plain',
2479 Data => Encode::encode( "UTF-8", $args{'Body'} ),
2483 if ( $args{'AttachmentFieldName'} ) {
2485 my $cgi_object = $m->cgi_object;
2486 my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} );
2487 if ( defined $filehandle && length $filehandle ) {
2489 my ( @content, $buffer );
2490 while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) {
2491 push @content, $buffer;
2494 my $uploadinfo = $cgi_object->uploadInfo($filehandle);
2496 my $filename = Encode::decode("UTF-8","$filehandle");
2497 $filename =~ s{^.*[\\/]}{};
2500 Type => $uploadinfo->{'Content-Type'},
2501 Filename => Encode::encode("UTF-8",$filename),
2502 Data => \@content, # Bytes, as read directly from the file, above
2504 if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) {
2505 $Message->head->replace( 'Subject' => Encode::encode( "UTF-8", $filename ) );
2508 # Attachment parts really shouldn't get a Message-ID or "interface"
2509 $Message->head->delete('Message-ID');
2510 $Message->head->delete('X-RT-Interface');
2514 $Message->make_singlepart;
2516 RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8
2524 =head2 ParseDateToISO
2526 Takes a date in an arbitrary format.
2527 Returns an ISO date and time in GMT
2531 sub ParseDateToISO {
2534 my $date_obj = RT::Date->new( $session{'CurrentUser'} );
2536 Format => 'unknown',
2539 return ( $date_obj->ISO );
2544 sub ProcessACLChanges {
2545 my $ARGSref = shift;
2547 #XXX: why don't we get ARGSref like in other Process* subs?
2551 foreach my $arg ( keys %$ARGSref ) {
2552 next unless ( $arg =~ /^(GrantRight|RevokeRight)-(\d+)-(.+?)-(\d+)$/ );
2554 my ( $method, $principal_id, $object_type, $object_id ) = ( $1, $2, $3, $4 );
2557 if ( UNIVERSAL::isa( $ARGSref->{$arg}, 'ARRAY' ) ) {
2558 @rights = @{ $ARGSref->{$arg} };
2560 @rights = $ARGSref->{$arg};
2562 @rights = grep $_, @rights;
2563 next unless @rights;
2565 my $principal = RT::Principal->new( $session{'CurrentUser'} );
2566 $principal->Load($principal_id);
2569 if ( $object_type eq 'RT::System' ) {
2571 } elsif ( $object_type->DOES('RT::Record::Role::Rights') ) {
2572 $obj = $object_type->new( $session{'CurrentUser'} );
2573 $obj->Load($object_id);
2574 unless ( $obj->id ) {
2575 $RT::Logger->error("couldn't load $object_type #$object_id");
2579 $RT::Logger->error("object type '$object_type' is incorrect");
2580 push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) );
2584 foreach my $right (@rights) {
2585 my ( $val, $msg ) = $principal->$method( Object => $obj, Right => $right );
2586 push( @results, $msg );
2596 ProcessACLs expects values from a series of checkboxes that describe the full
2597 set of rights a principal should have on an object.
2599 It expects form inputs with names like SetRights-PrincipalId-ObjType-ObjId
2600 instead of with the prefixes Grant/RevokeRight. Each input should be an array
2601 listing the rights the principal should have, and ProcessACLs will modify the
2602 current rights to match. Additionally, the previously unused CheckACL input
2603 listing PrincipalId-ObjType-ObjId is now used to catch cases when all the
2604 rights are removed from a principal and as such no SetRights input is
2610 my $ARGSref = shift;
2611 my (%state, @results);
2613 my $CheckACL = $ARGSref->{'CheckACL'};
2614 my @check = grep { defined } (ref $CheckACL eq 'ARRAY' ? @$CheckACL : $CheckACL);
2616 # Check if we want to grant rights to a previously rights-less user
2617 for my $type (qw(user group)) {
2618 my $principal = _ParseACLNewPrincipal($ARGSref, $type)
2621 unless ($principal->PrincipalId) {
2622 push @results, loc("Couldn't load the specified principal");
2626 my $principal_id = $principal->PrincipalId;
2628 # Turn our addprincipal rights spec into a real one
2629 for my $arg (keys %$ARGSref) {
2630 next unless $arg =~ /^SetRights-addprincipal-(.+?-\d+)$/;
2632 my $tuple = "$principal_id-$1";
2633 my $key = "SetRights-$tuple";
2635 # If we have it already, that's odd, but merge them
2636 if (grep { $_ eq $tuple } @check) {
2637 $ARGSref->{$key} = [
2638 (ref $ARGSref->{$key} eq 'ARRAY' ? @{$ARGSref->{$key}} : $ARGSref->{$key}),
2639 (ref $ARGSref->{$arg} eq 'ARRAY' ? @{$ARGSref->{$arg}} : $ARGSref->{$arg}),
2642 $ARGSref->{$key} = $ARGSref->{$arg};
2643 push @check, $tuple;
2648 # Build our rights state for each Principal-Object tuple
2649 foreach my $arg ( keys %$ARGSref ) {
2650 next unless $arg =~ /^SetRights-(\d+-.+?-\d+)$/;
2653 my $value = $ARGSref->{$arg};
2654 my @rights = grep { $_ } (ref $value eq 'ARRAY' ? @$value : $value);
2655 next unless @rights;
2657 $state{$tuple} = { map { $_ => 1 } @rights };
2660 foreach my $tuple (List::MoreUtils::uniq @check) {
2661 next unless $tuple =~ /^(\d+)-(.+?)-(\d+)$/;
2663 my ( $principal_id, $object_type, $object_id ) = ( $1, $2, $3 );
2665 my $principal = RT::Principal->new( $session{'CurrentUser'} );
2666 $principal->Load($principal_id);
2669 if ( $object_type eq 'RT::System' ) {
2671 } elsif ( $object_type->DOES('RT::Record::Role::Rights') ) {
2672 $obj = $object_type->new( $session{'CurrentUser'} );
2673 $obj->Load($object_id);
2674 unless ( $obj->id ) {
2675 $RT::Logger->error("couldn't load $object_type #$object_id");
2679 $RT::Logger->error("object type '$object_type' is incorrect");
2680 push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) );
2684 my $acls = RT::ACL->new($session{'CurrentUser'});
2685 $acls->LimitToObject( $obj );
2686 $acls->LimitToPrincipal( Id => $principal_id );
2688 while ( my $ace = $acls->Next ) {
2689 my $right = $ace->RightName;
2691 # Has right and should have right
2692 next if delete $state{$tuple}->{$right};
2694 # Has right and shouldn't have right
2695 my ($val, $msg) = $principal->RevokeRight( Object => $obj, Right => $right );
2696 push @results, $msg;
2699 # For everything left, they don't have the right but they should
2700 for my $right (keys %{ $state{$tuple} || {} }) {
2701 delete $state{$tuple}->{$right};
2702 my ($val, $msg) = $principal->GrantRight( Object => $obj, Right => $right );
2703 push @results, $msg;
2706 # Check our state for leftovers
2707 if ( keys %{ $state{$tuple} || {} } ) {
2708 my $missed = join '|', %{$state{$tuple} || {}};
2710 "Uh-oh, it looks like we somehow missed a right in "
2711 ."ProcessACLs. Here's what was leftover: $missed"
2719 =head2 _ParseACLNewPrincipal
2721 Takes a hashref of C<%ARGS> and a principal type (C<user> or C<group>). Looks
2722 for the presence of rights being added on a principal of the specified type,
2723 and returns undef if no new principal is being granted rights. Otherwise loads
2724 up an L<RT::User> or L<RT::Group> object and returns it. Note that the object
2725 may not be successfully loaded, and you should check C<->id> yourself.
2729 sub _ParseACLNewPrincipal {
2730 my $ARGSref = shift;
2731 my $type = lc shift;
2732 my $key = "AddPrincipalForRights-$type";
2734 return unless $ARGSref->{$key};
2737 if ( $type eq 'user' ) {
2738 $principal = RT::User->new( $session{'CurrentUser'} );
2739 $principal->LoadByCol( Name => $ARGSref->{$key} );
2741 elsif ( $type eq 'group' ) {
2742 $principal = RT::Group->new( $session{'CurrentUser'} );
2743 $principal->LoadUserDefinedGroup( $ARGSref->{$key} );
2749 =head2 UpdateRecordObj ( ARGSRef => \%ARGS, Object => RT::Record, AttributesRef => \@attribs)
2751 @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.
2753 Returns an array of success/failure messages
2757 sub UpdateRecordObject {
2760 AttributesRef => undef,
2762 AttributePrefix => undef,
2766 my $Object = $args{'Object'};
2767 my @results = $Object->Update(
2768 AttributesRef => $args{'AttributesRef'},
2769 ARGSRef => $args{'ARGSRef'},
2770 AttributePrefix => $args{'AttributePrefix'},
2778 sub ProcessCustomFieldUpdates {
2780 CustomFieldObj => undef,
2785 my $Object = $args{'CustomFieldObj'};
2786 my $ARGSRef = $args{'ARGSRef'};
2788 my @attribs = qw(Name Type Description Queue SortOrder);
2789 my @results = UpdateRecordObject(
2790 AttributesRef => \@attribs,
2795 my $prefix = "CustomField-" . $Object->Id;
2796 if ( $ARGSRef->{"$prefix-AddValue-Name"} ) {
2797 my ( $addval, $addmsg ) = $Object->AddValue(
2798 Name => $ARGSRef->{"$prefix-AddValue-Name"},
2799 Description => $ARGSRef->{"$prefix-AddValue-Description"},
2800 SortOrder => $ARGSRef->{"$prefix-AddValue-SortOrder"},
2802 push( @results, $addmsg );
2806 = ( ref $ARGSRef->{"$prefix-DeleteValue"} eq 'ARRAY' )
2807 ? @{ $ARGSRef->{"$prefix-DeleteValue"} }
2808 : ( $ARGSRef->{"$prefix-DeleteValue"} );
2810 foreach my $id (@delete_values) {
2811 next unless defined $id;
2812 my ( $err, $msg ) = $Object->DeleteValue($id);
2813 push( @results, $msg );
2816 my $vals = $Object->Values();
2817 while ( my $cfv = $vals->Next() ) {
2818 if ( my $so = $ARGSRef->{ "$prefix-SortOrder" . $cfv->Id } ) {
2819 if ( $cfv->SortOrder != $so ) {
2820 my ( $err, $msg ) = $cfv->SetSortOrder($so);
2821 push( @results, $msg );
2831 =head2 ProcessTicketBasics ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2833 Returns an array of results messages.
2837 sub ProcessTicketBasics {
2845 my $TicketObj = $args{'TicketObj'};
2846 my $ARGSRef = $args{'ARGSRef'};
2848 my $OrigOwner = $TicketObj->Owner;
2863 # Canonicalize Queue and Owner to their IDs if they aren't numeric
2864 for my $field (qw(Queue Owner)) {
2865 if ( $ARGSRef->{$field} and ( $ARGSRef->{$field} !~ /^(\d+)$/ ) ) {
2866 my $class = $field eq 'Owner' ? "RT::User" : "RT::$field";
2867 my $temp = $class->new(RT->SystemUser);
2868 $temp->Load( $ARGSRef->{$field} );
2870 $ARGSRef->{$field} = $temp->id;
2875 # Status isn't a field that can be set to a null value.
2876 # RT core complains if you try
2877 delete $ARGSRef->{'Status'} unless $ARGSRef->{'Status'};
2879 my @results = UpdateRecordObject(
2880 AttributesRef => \@attribs,
2881 Object => $TicketObj,
2882 ARGSRef => $ARGSRef,
2885 # We special case owner changing, so we can use ForceOwnerChange
2886 if ( $ARGSRef->{'Owner'}
2887 && $ARGSRef->{'Owner'} !~ /\D/
2888 && ( $OrigOwner != $ARGSRef->{'Owner'} ) ) {
2890 if ( $ARGSRef->{'ForceOwnerChange'} ) {
2891 $ChownType = "Force";
2897 my ( $val, $msg ) = $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType );
2898 push( @results, $msg );
2906 sub ProcessTicketReminders {
2913 my $Ticket = $args{'TicketObj'};
2914 my $args = $args{'ARGSRef'};
2917 my $reminder_collection = $Ticket->Reminders->Collection;
2919 if ( $args->{'update-reminders'} ) {
2920 while ( my $reminder = $reminder_collection->Next ) {
2921 my $resolve_status = $reminder->LifecycleObj->ReminderStatusOnResolve;
2922 my ( $status, $msg, $old_subject, @subresults );
2923 if ( $reminder->Status ne $resolve_status
2924 && $args->{ 'Complete-Reminder-' . $reminder->id } )
2926 ( $status, $msg ) = $Ticket->Reminders->Resolve($reminder);
2927 push @subresults, $msg;
2929 elsif ( $reminder->Status eq $resolve_status
2930 && !$args->{ 'Complete-Reminder-' . $reminder->id } )
2932 ( $status, $msg ) = $Ticket->Reminders->Open($reminder);
2933 push @subresults, $msg;
2937 exists( $args->{ 'Reminder-Subject-' . $reminder->id } )
2938 && ( $reminder->Subject ne
2939 $args->{ 'Reminder-Subject-' . $reminder->id } )
2942 $old_subject = $reminder->Subject;
2944 $reminder->SetSubject(
2945 $args->{ 'Reminder-Subject-' . $reminder->id } );
2946 push @subresults, $msg;
2950 exists( $args->{ 'Reminder-Owner-' . $reminder->id } )
2951 && ( $reminder->Owner !=
2952 $args->{ 'Reminder-Owner-' . $reminder->id } )
2956 $reminder->SetOwner(
2957 $args->{ 'Reminder-Owner-' . $reminder->id }, "Force" );
2958 push @subresults, $msg;
2961 if ( exists( $args->{ 'Reminder-Due-' . $reminder->id } )
2962 && $args->{ 'Reminder-Due-' . $reminder->id } ne '' )
2964 my $DateObj = RT::Date->new( $session{'CurrentUser'} );
2965 my $due = $args->{ 'Reminder-Due-' . $reminder->id };
2968 Format => 'unknown',
2971 if ( $DateObj->Unix != $reminder->DueObj->Unix ) {
2972 ( $status, $msg ) = $reminder->SetDue( $DateObj->ISO );
2975 $msg = loc( "invalid due date: [_1]", $due );
2978 push @subresults, $msg;
2981 push @results, map {
2982 loc( "Reminder '[_1]': [_2]", $old_subject || $reminder->Subject, $_ )
2987 if ( $args->{'NewReminder-Subject'} ) {
2988 my $due_obj = RT::Date->new( $session{'CurrentUser'} );
2990 Format => 'unknown',
2991 Value => $args->{'NewReminder-Due'}
2993 my ( $status, $msg ) = $Ticket->Reminders->Add(
2994 Subject => $args->{'NewReminder-Subject'},
2995 Owner => $args->{'NewReminder-Owner'},
2996 Due => $due_obj->ISO
3000 loc( "Reminder '[_1]': [_2]", $args->{'NewReminder-Subject'}, loc("Created") )
3003 push @results, $msg;
3009 sub ProcessObjectCustomFieldUpdates {
3011 my $ARGSRef = $args{'ARGSRef'};
3014 # Build up a list of objects that we want to work with
3015 my %custom_fields_to_mod = _ParseObjectCustomFieldArgs($ARGSRef);
3017 # For each of those objects
3018 foreach my $class ( keys %custom_fields_to_mod ) {
3019 foreach my $id ( keys %{ $custom_fields_to_mod{$class} } ) {
3020 my $Object = $args{'Object'};
3021 $Object = $class->new( $session{'CurrentUser'} )
3022 unless $Object && ref $Object eq $class;
3024 $Object->Load($id) unless ( $Object->id || 0 ) == $id;
3025 unless ( $Object->id ) {
3026 $RT::Logger->warning("Couldn't load object $class #$id");
3030 foreach my $cf ( keys %{ $custom_fields_to_mod{$class}{$id} } ) {
3031 my $CustomFieldObj = RT::CustomField->new( $session{'CurrentUser'} );
3032 $CustomFieldObj->SetContextObject($Object);
3033 $CustomFieldObj->LoadById($cf);
3034 unless ( $CustomFieldObj->id ) {
3035 $RT::Logger->warning("Couldn't load custom field #$cf");
3038 my @groupings = sort keys %{ $custom_fields_to_mod{$class}{$id}{$cf} };
3039 if (@groupings > 1) {
3040 # Check for consistency, in case of JS fail
3041 for my $key (qw/AddValue Value Values DeleteValues DeleteValueIds/) {
3042 my $base = $custom_fields_to_mod{$class}{$id}{$cf}{$groupings[0]}{$key};
3043 $base = [ $base ] unless ref $base;
3044 for my $grouping (@groupings[1..$#groupings]) {
3045 my $other = $custom_fields_to_mod{$class}{$id}{$cf}{$grouping}{$key};
3046 $other = [ $other ] unless ref $other;
3047 warn "CF $cf submitted with multiple differing values"
3048 if grep {$_} List::MoreUtils::pairwise {
3049 no warnings qw(uninitialized);
3051 } @{$base}, @{$other};
3054 # We'll just be picking the 1st grouping in the hash, alphabetically
3057 _ProcessObjectCustomFieldUpdates(
3058 Prefix => GetCustomFieldInputNamePrefix(
3060 CustomField => $CustomFieldObj,
3061 Grouping => $groupings[0],
3064 CustomField => $CustomFieldObj,
3065 ARGS => $custom_fields_to_mod{$class}{$id}{$cf}{ $groupings[0] },
3073 sub _ParseObjectCustomFieldArgs {
3074 my $ARGSRef = shift || {};
3075 my %custom_fields_to_mod;
3077 foreach my $arg ( keys %$ARGSRef ) {
3079 # format: Object-<object class>-<object id>-CustomField[:<grouping>]-<CF id>-<commands>
3080 # you can use GetCustomFieldInputName to generate the complement input name
3081 next unless $arg =~ /^Object-([\w:]+)-(\d*)-CustomField(?::(\w+))?-(\d+)-(.*)$/;
3083 # For each of those objects, find out what custom fields we want to work with.
3084 # Class ID CF grouping command
3085 $custom_fields_to_mod{$1}{ $2 || 0 }{$4}{$3 || ''}{$5} = $ARGSRef->{$arg};
3088 return wantarray ? %custom_fields_to_mod : \%custom_fields_to_mod;
3091 sub _ProcessObjectCustomFieldUpdates {
3093 my $cf = $args{'CustomField'};
3094 my $cf_type = $cf->Type || '';
3096 # Remove blank Values since the magic field will take care of this. Sometimes
3097 # the browser gives you a blank value which causes CFs to be processed twice
3098 if ( defined $args{'ARGS'}->{'Values'}
3099 && !length $args{'ARGS'}->{'Values'}
3100 && ($args{'ARGS'}->{'Values-Magic'}) )
3102 delete $args{'ARGS'}->{'Values'};
3106 foreach my $arg ( keys %{ $args{'ARGS'} } ) {
3108 # skip category argument
3109 next if $arg =~ /-Category$/;
3111 # since http won't pass in a form element with a null value, we need
3113 if ( $arg =~ /-Magic$/ ) {
3115 # We don't care about the magic, if there's really a values element;
3116 next if defined $args{'ARGS'}->{'Value'} && length $args{'ARGS'}->{'Value'};
3117 next if defined $args{'ARGS'}->{'Values'} && length $args{'ARGS'}->{'Values'};
3119 # "Empty" values does not mean anything for Image and Binary fields
3120 next if $cf_type =~ /^(?:Image|Binary)$/;
3123 $args{'ARGS'}->{'Values'} = undef;
3126 my @values = _NormalizeObjectCustomFieldValue(
3128 Param => $args{'Prefix'} . $arg,
3129 Value => $args{'ARGS'}->{$arg}
3132 # "Empty" values still don't mean anything for Image and Binary fields
3133 next if $cf_type =~ /^(?:Image|Binary)$/ and not @values;
3135 if ( $arg eq 'AddValue' || $arg eq 'Value' ) {
3136 foreach my $value (@values) {
3137 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
3141 push( @results, $msg );
3143 } elsif ( $arg eq 'Upload' ) {
3144 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( %{$values[0]}, Field => $cf, );
3145 push( @results, $msg );
3146 } elsif ( $arg eq 'DeleteValues' ) {
3147 foreach my $value (@values) {
3148 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
3152 push( @results, $msg );
3154 } elsif ( $arg eq 'DeleteValueIds' ) {
3155 foreach my $value (@values) {
3156 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
3160 push( @results, $msg );
3162 } elsif ( $arg eq 'Values' ) {
3163 my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id );
3166 foreach my $value (@values) {
3167 if ( my $entry = $cf_values->HasEntry($value) ) {
3168 $values_hash{ $entry->id } = 1;
3172 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
3176 push( @results, $msg );
3177 $values_hash{$val} = 1 if $val;
3180 # For Date Cfs, @values is empty when there is no changes (no datas in form input)
3181 return @results if ( $cf->Type =~ /^Date(?:Time)?$/ && ! @values );
3183 $cf_values->RedoSearch;
3184 while ( my $cf_value = $cf_values->Next ) {
3185 next if $values_hash{ $cf_value->id };
3187 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
3189 ValueId => $cf_value->id
3191 push( @results, $msg );
3196 loc("User asked for an unknown update type for custom field [_1] for [_2] object #[_3]",
3197 $cf->Name, ref $args{'Object'},
3206 sub ProcessObjectCustomFieldUpdatesForCreate {
3209 ContextObject => undef,
3212 my $context = $args{'ContextObject'};
3214 my %custom_fields = _ParseObjectCustomFieldArgs( $args{'ARGSRef'} );
3216 for my $class (keys %custom_fields) {
3217 # we're only interested in new objects, so only look at $id == 0
3218 for my $cfid (keys %{ $custom_fields{$class}{0} || {} }) {
3219 my $cf = RT::CustomField->new( $session{'CurrentUser'} );
3221 my $system_cf = RT::CustomField->new( RT->SystemUser );
3222 $system_cf->LoadById($cfid);
3223 if ($system_cf->ValidateContextObject($context)) {
3224 $cf->SetContextObject($context);
3227 sprintf "Invalid context object %s (%d) for CF %d; skipping CF",
3228 ref $context, $context->id, $system_cf->id
3233 $cf->LoadById($cfid);
3236 RT->Logger->warning("Couldn't load custom field #$cfid");
3240 my @groupings = sort keys %{ $custom_fields{$class}{0}{$cfid} };
3241 if (@groupings > 1) {
3242 # Check for consistency, in case of JS fail
3243 for my $key (qw/AddValue Value Values DeleteValues DeleteValueIds/) {
3244 warn "CF $cfid submitted with multiple differing $key"
3245 if grep {($custom_fields{$class}{0}{$cfid}{$_}{$key} || '')
3246 ne ($custom_fields{$class}{0}{$cfid}{$groupings[0]}{$key} || '')}
3249 # We'll just be picking the 1st grouping in the hash, alphabetically
3253 my $name_prefix = GetCustomFieldInputNamePrefix(
3255 Grouping => $groupings[0],
3257 while (my ($arg, $value) = each %{ $custom_fields{$class}{0}{$cfid}{$groupings[0]} }) {
3258 # Values-Magic doesn't matter on create; no previous values are being removed
3259 # Category is irrelevant for the actual value
3260 next if $arg =~ /-Magic$/ or $arg =~ /-Category$/;
3263 _NormalizeObjectCustomFieldValue(
3265 Param => $name_prefix . $arg,
3270 $parsed{"CustomField-$cfid"} = \@values if @values;
3274 return wantarray ? %parsed : \%parsed;
3277 sub _NormalizeObjectCustomFieldValue {
3282 my $cf_type = $args{CustomField}->Type;
3285 if ( ref $args{'Value'} eq 'ARRAY' ) {
3286 @values = @{ $args{'Value'} };
3287 } elsif ( $cf_type =~ /text/i ) { # Both Text and Wikitext
3288 @values = ( $args{'Value'} );
3290 @values = split /\r*\n/, $args{'Value'}
3291 if defined $args{'Value'};
3293 @values = grep length, map {
3299 grep defined, @values;
3301 if ($args{'Param'} =~ /-Upload$/ and $cf_type =~ /^(Image|Binary)$/) {
3302 @values = _UploadedFile( $args{'Param'} ) || ();
3308 =head2 ProcessTicketWatchers ( TicketObj => $Ticket, ARGSRef => \%ARGS );
3310 Returns an array of results messages.
3314 sub ProcessTicketWatchers {
3322 my $Ticket = $args{'TicketObj'};
3323 my $ARGSRef = $args{'ARGSRef'};
3327 foreach my $key ( keys %$ARGSRef ) {
3329 # Delete deletable watchers
3330 if ( $key =~ /^Ticket-DeleteWatcher-Type-(.*)-Principal-(\d+)$/ ) {
3331 my ( $code, $msg ) = $Ticket->DeleteWatcher(
3335 push @results, $msg;
3338 # Delete watchers in the simple style demanded by the bulk manipulator
3339 elsif ( $key =~ /^Delete(Requestor|Cc|AdminCc)$/ ) {
3340 my ( $code, $msg ) = $Ticket->DeleteWatcher(
3341 Email => $ARGSRef->{$key},
3344 push @results, $msg;
3347 # Add new wathchers by email address
3348 elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor)$/
3349 and $key =~ /^WatcherTypeEmail(\d*)$/ )
3352 #They're in this order because otherwise $1 gets clobbered :/
3353 my ( $code, $msg ) = $Ticket->AddWatcher(
3354 Type => $ARGSRef->{$key},
3355 Email => $ARGSRef->{ "WatcherAddressEmail" . $1 }
3357 push @results, $msg;
3360 #Add requestors in the simple style demanded by the bulk manipulator
3361 elsif ( $key =~ /^Add(Requestor|Cc|AdminCc)$/ ) {
3362 my ( $code, $msg ) = $Ticket->AddWatcher(
3364 Email => $ARGSRef->{$key}
3366 push @results, $msg;
3369 # Add new watchers by owner
3370 elsif ( $key =~ /^Ticket-AddWatcher-Principal-(\d*)$/ ) {
3371 my $principal_id = $1;
3372 my $form = $ARGSRef->{$key};
3373 foreach my $value ( ref($form) ? @{$form} : ($form) ) {
3374 next unless $value =~ /^(?:AdminCc|Cc|Requestor)$/i;
3376 my ( $code, $msg ) = $Ticket->AddWatcher(
3378 PrincipalId => $principal_id
3380 push @results, $msg;
3390 =head2 ProcessTicketDates ( TicketObj => $Ticket, ARGSRef => \%ARGS );
3392 Returns an array of results messages.
3396 sub ProcessTicketDates {
3403 my $Ticket = $args{'TicketObj'};
3404 my $ARGSRef = $args{'ARGSRef'};
3409 my @date_fields = qw(
3416 #Run through each field in this list. update the value if apropriate
3417 foreach my $field (@date_fields) {
3418 next unless exists $ARGSRef->{ $field . '_Date' };
3419 next if $ARGSRef->{ $field . '_Date' } eq '';
3423 my $DateObj = RT::Date->new( $session{'CurrentUser'} );
3425 Format => 'unknown',
3426 Value => $ARGSRef->{ $field . '_Date' }
3429 my $obj = $field . "Obj";
3430 if ( $DateObj->Unix != $Ticket->$obj()->Unix() ) {
3431 my $method = "Set$field";
3432 my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO );
3433 push @results, "$msg";
3443 =head2 ProcessTicketLinks ( TicketObj => $Ticket, ARGSRef => \%ARGS );
3445 Returns an array of results messages.
3449 sub ProcessTicketLinks {
3457 my $Ticket = $args{'TicketObj'};
3458 my $TicketId = $args{'TicketId'} || $Ticket->Id;
3459 my $ARGSRef = $args{'ARGSRef'};
3461 my (@results) = ProcessRecordLinks(
3462 %args, RecordObj => $Ticket, RecordId => $TicketId, ARGSRef => $ARGSRef,
3465 #Merge if we need to
3466 my $input = $TicketId .'-MergeInto';
3467 if ( $ARGSRef->{ $input } ) {
3468 $ARGSRef->{ $input } =~ s/\s+//g;
3469 my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $input } );
3470 push @results, $msg;
3477 sub ProcessRecordLinks {
3485 my $Record = $args{'RecordObj'};
3486 my $RecordId = $args{'RecordId'} || $Record->Id;
3487 my $ARGSRef = $args{'ARGSRef'};
3491 # Delete links that are gone gone gone.
3492 foreach my $arg ( keys %$ARGSRef ) {
3493 if ( $arg =~ /DeleteLink-(.*?)-(DependsOn|MemberOf|RefersTo)-(.*)$/ ) {
3498 my ( $val, $msg ) = $Record->DeleteLink(
3504 push @results, $msg;
3510 my @linktypes = qw( DependsOn MemberOf RefersTo );
3512 foreach my $linktype (@linktypes) {
3513 my $input = $RecordId .'-'. $linktype;
3514 if ( $ARGSRef->{ $input } ) {
3515 $ARGSRef->{ $input } = join( ' ', @{ $ARGSRef->{ $input } } )
3516 if ref $ARGSRef->{ $input };
3518 for my $luri ( split( / /, $ARGSRef->{ $input } ) ) {
3520 $luri =~ s/\s+$//; # Strip trailing whitespace
3521 my ( $val, $msg ) = $Record->AddLink(
3525 push @results, $msg;
3528 $input = $linktype .'-'. $RecordId;
3529 if ( $ARGSRef->{ $input } ) {
3530 $ARGSRef->{ $input } = join( ' ', @{ $ARGSRef->{ $input } } )
3531 if ref $ARGSRef->{ $input };
3533 for my $luri ( split( / /, $ARGSRef->{ $input } ) ) {
3535 my ( $val, $msg ) = $Record->AddLink(
3540 push @results, $msg;
3548 =head2 ProcessLinksForCreate
3550 Takes a hash with a single key, C<ARGSRef>, the value of which is a hashref to
3553 Converts and returns submitted args in the form of C<new-LINKTYPE> and
3554 C<LINKTYPE-new> into their appropriate directional link types. For example,
3555 C<new-DependsOn> becomes C<DependsOn> and C<DependsOn-new> becomes
3556 C<DependedOnBy>. The incoming arg values are split on whitespace and
3557 normalized into arrayrefs before being returned.
3559 Primarily used by object creation pages for transforming incoming form inputs
3560 from F</Elements/EditLinks> into arguments appropriate for individual record
3563 Returns a hashref in scalar context and a hash in list context.
3567 sub ProcessLinksForCreate {
3571 foreach my $type ( keys %RT::Link::DIRMAP ) {
3572 for ([Base => "new-$type"], [Target => "$type-new"]) {
3573 my ($direction, $key) = @$_;
3574 next unless $args{ARGSRef}->{$key};
3575 $links{ $RT::Link::DIRMAP{$type}->{$direction} } = [
3576 grep $_, split ' ', $args{ARGSRef}->{$key}
3580 return wantarray ? %links : \%links;
3583 =head2 ProcessTransactionSquelching
3585 Takes a hashref of the submitted form arguments, C<%ARGS>.
3587 Returns a hash of squelched addresses.
3591 sub ProcessTransactionSquelching {
3593 my %checked = map { $_ => 1 } grep { defined }
3594 ( ref $args->{'TxnSendMailTo'} eq "ARRAY" ? @{$args->{'TxnSendMailTo'}} :
3595 defined $args->{'TxnSendMailTo'} ? ($args->{'TxnSendMailTo'}) :
3597 my %squelched = map { $_ => 1 } grep { not $checked{$_} } split /,/, ($args->{'TxnRecipients'}||'');
3601 sub ProcessRecordBulkCustomFields {
3602 my %args = (RecordObj => undef, ARGSRef => {}, @_);
3604 my $ARGSRef = $args{'ARGSRef'};
3609 foreach my $key ( keys %$ARGSRef ) {
3610 next unless $key =~ /^Bulk-(Add|Delete)-CustomField-(\d+)-(.*)$/;
3611 my ($op, $cfid, $rest) = ($1, $2, $3);
3612 next if $rest =~ /-Category$/;
3614 my $res = $data{$cfid} ||= {};
3615 unless (keys %$res) {
3616 my $cf = RT::CustomField->new( $session{'CurrentUser'} );
3618 next unless $cf->Id;
3623 if ( $op eq 'Delete' && $rest eq 'AllValues' ) {
3624 $res->{'DeleteAll'} = $ARGSRef->{$key};
3628 my @values = _NormalizeObjectCustomFieldValue(
3629 CustomField => $res->{'cf'},
3630 Value => $ARGSRef->{$key},
3633 next unless @values;
3634 $res->{$op} = \@values;
3637 while ( my ($cfid, $data) = each %data ) {
3638 my $current_values = $args{'RecordObj'}->CustomFieldValues( $cfid );
3640 # just add one value for fields with single value
3641 if ( $data->{'Add'} && $data->{'cf'}->MaxValues == 1 ) {
3642 next if $current_values->HasEntry($data->{Add}[-1]);
3644 my ( $id, $msg ) = $args{'RecordObj'}->AddCustomFieldValue(
3646 Value => $data->{'Add'}[-1],
3648 push @results, $msg;
3652 if ( $data->{'DeleteAll'} ) {
3653 while ( my $value = $current_values->Next ) {
3654 my ( $id, $msg ) = $args{'RecordObj'}->DeleteCustomFieldValue(
3656 ValueId => $value->id,
3658 push @results, $msg;
3661 foreach my $value ( @{ $data->{'Delete'} || [] } ) {
3662 my $entry = $current_values->HasEntry($value);
3665 my ( $id, $msg ) = $args{'RecordObj'}->DeleteCustomFieldValue(
3667 ValueId => $entry->id,
3669 push @results, $msg;
3671 foreach my $value ( @{ $data->{'Add'} || [] } ) {
3672 next if $current_values->HasEntry($value);
3674 my ( $id, $msg ) = $args{'RecordObj'}->AddCustomFieldValue(
3678 push @results, $msg;
3684 =head2 _UploadedFile ( $arg );
3686 Takes a CGI parameter name; if a file is uploaded under that name,
3687 return a hash reference suitable for AddCustomFieldValue's use:
3688 C<( Value => $filename, LargeContent => $content, ContentType => $type )>.
3690 Returns C<undef> if no files were uploaded in the C<$arg> field.
3696 my $cgi_object = $m->cgi_object;
3697 my $fh = $cgi_object->upload($arg) or return undef;
3698 my $upload_info = $cgi_object->uploadInfo($fh);
3700 my $filename = "$fh";
3701 $filename =~ s#^.*[\\/]##;
3706 LargeContent => do { local $/; scalar <$fh> },
3707 ContentType => $upload_info->{'Content-Type'},
3711 sub GetColumnMapEntry {
3712 my %args = ( Map => {}, Name => '', Attribute => undef, @_ );
3714 # deal with the simplest thing first
3715 if ( $args{'Map'}{ $args{'Name'} } ) {
3716 return $args{'Map'}{ $args{'Name'} }{ $args{'Attribute'} };
3720 elsif ( my ( $mainkey, $subkey ) = $args{'Name'} =~ /^(.*?)\.(.+)$/ ) {
3721 $subkey =~ s/^\{(.*)\}$/$1/;
3722 return undef unless $args{'Map'}->{$mainkey};
3723 return $args{'Map'}{$mainkey}{ $args{'Attribute'} }
3724 unless ref $args{'Map'}{$mainkey}{ $args{'Attribute'} } eq 'CODE';
3726 return sub { $args{'Map'}{$mainkey}{ $args{'Attribute'} }->( @_, $subkey ) };
3731 sub ProcessColumnMapValue {
3733 my %args = ( Arguments => [], Escape => 1, @_ );
3736 if ( UNIVERSAL::isa( $value, 'CODE' ) ) {
3737 my @tmp = $value->( @{ $args{'Arguments'} } );
3738 return ProcessColumnMapValue( ( @tmp > 1 ? \@tmp : $tmp[0] ), %args );
3739 } elsif ( UNIVERSAL::isa( $value, 'ARRAY' ) ) {
3740 return join '', map ProcessColumnMapValue( $_, %args ), @$value;
3741 } elsif ( UNIVERSAL::isa( $value, 'SCALAR' ) ) {
3745 if ($args{'Escape'}) {
3746 $value = $m->interp->apply_escapes( $value, 'h' );
3747 $value =~ s/\n/<br>/g if defined $value;
3753 =head2 GetPrincipalsMap OBJECT, CATEGORIES
3755 Returns an array suitable for passing to /Admin/Elements/EditRights with the
3756 principal collections mapped from the categories given.
3760 sub GetPrincipalsMap {
3765 my $system = RT::Groups->new($session{'CurrentUser'});
3766 $system->LimitToSystemInternalGroups();
3767 $system->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3769 'System' => $system, # loc_left_pair
3774 my $groups = RT::Groups->new($session{'CurrentUser'});
3775 $groups->LimitToUserDefinedGroups();
3776 $groups->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3778 # Only show groups who have rights granted on this object
3779 $groups->WithGroupRight(
3782 IncludeSystemRights => 0,
3783 IncludeSubgroupMembers => 0,
3787 'User Groups' => $groups, # loc_left_pair
3792 my $roles = RT::Groups->new($session{'CurrentUser'});
3794 if ($object->isa("RT::CustomField")) {
3795 # If we're a custom field, show the global roles for our LookupType.
3796 my $class = $object->RecordClassFromLookupType;
3797 if ($class and $class->DOES("RT::Record::Role::Roles")) {
3798 $roles->LimitToRolesForObject(RT->System);
3799 $roles->Limit( FIELD => "Name", VALUE => $_, CASESENSITIVE => 0 )
3802 # No roles to show; so show nothing
3806 $roles->LimitToRolesForObject($object);
3810 $roles->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3812 'Roles' => $roles, # loc_left_pair
3818 my $Users = RT->PrivilegedUsers->UserMembersObj();
3819 $Users->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3821 # Only show users who have rights granted on this object
3822 my $group_members = $Users->WhoHaveGroupRight(
3825 IncludeSystemRights => 0,
3826 IncludeSubgroupMembers => 0,
3829 # Limit to UserEquiv groups
3830 my $groups = $Users->Join(
3831 ALIAS1 => $group_members,
3832 FIELD1 => 'GroupId',
3836 $Users->Limit( ALIAS => $groups, FIELD => 'Domain', VALUE => 'ACLEquivalence', CASESENSITIVE => 0 );
3837 $Users->Limit( ALIAS => $groups, FIELD => 'Name', VALUE => 'UserEquiv', CASESENSITIVE => 0 );
3840 'Users' => $Users, # loc_left_pair
3848 =head2 _load_container_object ( $type, $id );
3850 Instantiate container object for saving searches.
3854 sub _load_container_object {
3855 my ( $obj_type, $obj_id ) = @_;
3856 return RT::SavedSearch->new( $session{'CurrentUser'} )->_load_privacy_object( $obj_type, $obj_id );
3859 =head2 _parse_saved_search ( $arg );
3861 Given a serialization string for saved search, and returns the
3862 container object and the search id.
3866 sub _parse_saved_search {
3868 return unless $spec;
3869 if ( $spec !~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) {
3876 return ( _load_container_object( $obj_type, $obj_id ), $search_id );
3879 =head2 ScrubHTML content
3881 Removes unsafe and undesired HTML from the passed content
3887 my $Content = shift;
3888 $SCRUBBER = _NewScrubber() unless $SCRUBBER;
3890 $Content = '' if !defined($Content);
3891 return $SCRUBBER->scrub($Content);
3896 Returns a new L<HTML::Scrubber> object.
3898 If you need to be more lax about what HTML tags and attributes are allowed,
3899 create C</opt/rt4/local/lib/RT/Interface/Web_Local.pm> with something like the
3902 package HTML::Mason::Commands;
3903 # Let tables through
3904 push @SCRUBBER_ALLOWED_TAGS, qw(TABLE THEAD TBODY TFOOT TR TD TH);
3909 our @SCRUBBER_ALLOWED_TAGS = qw(
3910 A B U P BR I HR BR SMALL EM FONT SPAN STRONG SUB SUP STRIKE H1 H2 H3 H4 H5
3911 H6 DIV UL OL LI DL DT DD PRE BLOCKQUOTE BDO
3914 our %SCRUBBER_ALLOWED_ATTRIBUTES = (
3915 # Match http, https, ftp, mailto and relative urls
3916 # XXX: we also scrub format strings with this module then allow simple config options
3917 href => qr{^(?:https?:|ftp:|mailto:|/|__Web(?:Path|HomePath|BaseURL|URL)__)}i,
3924 (?:(?:background-)?color: \s*
3925 (?:rgb\(\s* \d+, \s* \d+, \s* \d+ \s*\) | # rgb(d,d,d)
3926 \#[a-f0-9]{3,6} | # #fff or #ffffff
3927 [\w\-]+ # green, light-blue, etc.
3929 text-align: \s* \w+ |
3930 font-size: \s* [\w.\-]+ |
3931 font-family: \s* [\w\s"',.\-]+ |
3932 font-weight: \s* [\w\-]+ |
3934 border-style: \s* \w+ |
3935 border-color: \s* [#\w]+ |
3936 border-width: \s* [\s\w]+ |
3937 padding: \s* [\s\w]+ |
3938 margin: \s* [\s\w]+ |
3940 # MS Office styles, which are probably fine. If we don't, then any
3941 # associated styles in the same attribute get stripped.
3942 mso-[\w\-]+?: \s* [\w\s"',.\-]+
3944 +$ # one or more of these allowed properties from here 'till sunset
3946 dir => qr/^(rtl|ltr)$/i,
3947 lang => qr/^\w+(-\w+)?$/,
3950 our %SCRUBBER_RULES = ();
3952 # If we're displaying images, let embedded ones through
3953 if (RT->Config->Get('ShowTransactionImages') or RT->Config->Get('ShowRemoteImages')) {
3954 $SCRUBBER_RULES{'img'} = {
3960 push @src, qr/^cid:/i
3961 if RT->Config->Get('ShowTransactionImages');
3963 push @src, $SCRUBBER_ALLOWED_ATTRIBUTES{'href'}
3964 if RT->Config->Get('ShowRemoteImages');
3966 $SCRUBBER_RULES{'img'}->{'src'} = join "|", @src;
3970 require HTML::Scrubber;
3971 my $scrubber = HTML::Scrubber->new();
3973 if (HTML::Gumbo->require) {
3974 no warnings 'redefine';
3975 my $orig = \&HTML::Scrubber::scrub;
3976 *HTML::Scrubber::scrub = sub {
3979 eval { $_[0] = HTML::Gumbo->new->parse( $_[0] ); chomp $_[0] };
3980 warn "HTML::Gumbo pre-parse failed: $@" if $@;
3981 return $orig->($self, @_);
3983 push @SCRUBBER_ALLOWED_TAGS, qw/TABLE THEAD TBODY TFOOT TR TD TH/;
3984 $SCRUBBER_ALLOWED_ATTRIBUTES{$_} = 1 for
3985 qw/colspan rowspan align valign cellspacing cellpadding border width height/;
3991 %SCRUBBER_ALLOWED_ATTRIBUTES,
3992 '*' => 0, # require attributes be explicitly allowed
3995 $scrubber->deny(qw[*]);
3996 $scrubber->allow(@SCRUBBER_ALLOWED_TAGS);
3997 $scrubber->rules(%SCRUBBER_RULES);
3999 # Scrubbing comments is vital since IE conditional comments can contain
4000 # arbitrary HTML and we'd pass it right on through.
4001 $scrubber->comment(0);
4008 Redispatches to L<RT::Interface::Web/EncodeJSON>
4013 RT::Interface::Web::EncodeJSON(@_);
4018 return '' unless defined $value;
4019 $value =~ s/[^A-Za-z0-9_-]/_/g;
4023 sub GetCustomFieldInputName {
4024 RT::Interface::Web::GetCustomFieldInputName(@_);
4027 sub GetCustomFieldInputNamePrefix {
4028 RT::Interface::Web::GetCustomFieldInputNamePrefix(@_);
4031 package RT::Interface::Web;
4032 RT::Base->_ImportOverlays();