1 # BEGIN BPS TAGGED BLOCK {{{
5 # This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC
6 # <sales@bestpractical.com>
8 # (Except where explicitly superseded by other copyright notices)
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
21 # General Public License for more details.
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
30 # CONTRIBUTION SUBMISSION POLICY:
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
47 # END BPS TAGGED BLOCK }}}
49 ## Portions Copyright 2000 Tobias Brox <tobix@fsck.com>
51 ## This is a library of static subs to be used by the Mason web
64 package RT::Interface::Web;
66 use RT::SavedSearches;
68 use RT::Interface::Web::Menu;
69 use RT::Interface::Web::Session;
72 use List::MoreUtils qw();
75 =head2 SquishedCSS $style
81 my $style = shift or die "need name";
82 return $SQUISHED_CSS{$style} if $SQUISHED_CSS{$style};
83 require RT::Squish::CSS;
84 my $css = RT::Squish::CSS->new( Style => $style );
85 $SQUISHED_CSS{ $css->Style } = $css;
95 return $SQUISHED_JS if $SQUISHED_JS;
97 require RT::Squish::JS;
98 my $js = RT::Squish::JS->new();
105 Removes the cached CSS and JS entries, forcing them to be regenerated
115 =head2 EscapeUTF8 SCALARREF
117 does a css-busting but minimalist escaping of whatever html you're passing in.
123 return unless defined $$ref;
125 $$ref =~ s/&/&/g;
128 $$ref =~ s/\(/(/g;
129 $$ref =~ s/\)/)/g;
130 $$ref =~ s/"/"/g;
131 $$ref =~ s/'/'/g;
136 =head2 EscapeURI SCALARREF
138 Escapes URI component according to RFC2396
144 return unless defined $$ref;
147 $$ref =~ s/([^a-zA-Z0-9_.!~*'()-])/uc sprintf("%%%02X", ord($1))/eg;
150 =head2 EncodeJSON SCALAR
152 Encodes the SCALAR to JSON and returns a JSON string. SCALAR may be a simple
153 value or a reference.
158 JSON::to_json(shift, { utf8 => 1, allow_nonref => 1 });
161 sub _encode_surrogates {
162 my $uni = $_[0] - 0x10000;
163 return ($uni / 0x400 + 0xD800, $uni % 0x400 + 0xDC00);
168 return unless defined $$ref;
170 $$ref = "'" . join('',
172 chr($_) =~ /[a-zA-Z0-9]/ ? chr($_) :
173 $_ <= 255 ? sprintf("\\x%02X", $_) :
174 $_ <= 65535 ? sprintf("\\u%04X", $_) :
175 sprintf("\\u%X\\u%X", _encode_surrogates($_))
176 } unpack('U*', $$ref))
180 =head2 WebCanonicalizeInfo();
182 Different web servers set different environmental varibles. This
183 function must return something suitable for REMOTE_USER. By default,
184 just downcase $ENV{'REMOTE_USER'}
188 sub WebCanonicalizeInfo {
189 return $ENV{'REMOTE_USER'} ? lc $ENV{'REMOTE_USER'} : $ENV{'REMOTE_USER'};
194 =head2 WebExternalAutoInfo($user);
196 Returns a hash of user attributes, used when WebExternalAuto is set.
200 sub WebExternalAutoInfo {
205 # default to making Privileged users, even if they specify
206 # some other default Attributes
207 if ( !$RT::AutoCreate
208 || ( ref($RT::AutoCreate) && not exists $RT::AutoCreate->{Privileged} ) )
210 $user_info{'Privileged'} = 1;
213 if ( $^O !~ /^(?:riscos|MacOS|MSWin32|dos|os2)$/ ) {
215 # Populate fields with information from Unix /etc/passwd
217 my ( $comments, $realname ) = ( getpwnam($user) )[ 5, 6 ];
218 $user_info{'Comments'} = $comments if defined $comments;
219 $user_info{'RealName'} = $realname if defined $realname;
220 } elsif ( $^O eq 'MSWin32' and eval 'use Net::AdminMisc; 1' ) {
222 # Populate fields with information from NT domain controller
225 # and return the wad of stuff
233 if (RT->Config->Get('DevelMode')) {
234 require Module::Refresh;
235 Module::Refresh->refresh;
238 $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8");
240 $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ];
242 # Roll back any dangling transactions from a previous failed connection
243 $RT::Handle->ForceRollback() if $RT::Handle and $RT::Handle->TransactionDepth;
245 MaybeEnableSQLStatementLog();
247 # avoid reentrancy, as suggested by masonbook
248 local *HTML::Mason::Commands::session unless $HTML::Mason::Commands::m->is_subrequest;
250 $HTML::Mason::Commands::m->autoflush( $HTML::Mason::Commands::m->request_comp->attr('AutoFlush') )
251 if ( $HTML::Mason::Commands::m->request_comp->attr_exists('AutoFlush') );
256 local $HTML::Mason::Commands::DECODED_ARGS = $ARGS;
257 PreprocessTimeUpdates($ARGS);
260 MaybeShowInstallModePage();
262 $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS );
265 if ( _UserLoggedIn() ) {
266 # make user info up to date
267 $HTML::Mason::Commands::session{'CurrentUser'}
268 ->Load( $HTML::Mason::Commands::session{'CurrentUser'}->id );
271 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
274 # Process session-related callbacks before any auth attempts
275 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Session', CallbackPage => '/autohandler' );
277 MaybeRejectPrivateComponentRequest();
279 MaybeShowNoAuthPage($ARGS);
281 AttemptExternalAuth($ARGS) if RT->Config->Get('WebExternalAuthContinuous') or not _UserLoggedIn();
283 _ForceLogout() unless _UserLoggedIn();
285 # Process per-page authentication callbacks
286 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Auth', CallbackPage => '/autohandler' );
288 unless ( _UserLoggedIn() ) {
291 # Authenticate if the user is trying to login via user/pass query args
292 my ($authed, $msg) = AttemptPasswordAuthentication($ARGS);
295 my $m = $HTML::Mason::Commands::m;
297 # REST urls get a special 401 response
298 if ($m->request_comp->path =~ m{^/REST/\d+\.\d+/}) {
299 $HTML::Mason::Commands::r->content_type("text/plain");
300 $m->error_format("text");
301 $m->out("RT/$RT::VERSION 401 Credentials required\n");
302 $m->out("\n$msg\n") if $msg;
305 # Specially handle /index.html so that we get a nicer URL
306 elsif ( $m->request_comp->path eq '/index.html' ) {
307 my $next = SetNextPage($ARGS);
308 $m->comp('/NoAuth/Login.html', next => $next, actions => [$msg]);
312 TangentForLogin($ARGS, results => ($msg ? LoginError($msg) : undef));
317 MaybeShowInterstitialCSRFPage($ARGS);
319 # now it applies not only to home page, but any dashboard that can be used as a workspace
320 $HTML::Mason::Commands::session{'home_refresh_interval'} = $ARGS->{'HomeRefreshInterval'}
321 if ( $ARGS->{'HomeRefreshInterval'} );
323 # Process per-page global callbacks
324 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Default', CallbackPage => '/autohandler' );
326 ShowRequestedPage($ARGS);
327 LogRecordedSQLStatements(RequestData => {
328 Path => $HTML::Mason::Commands::m->request_comp->path,
331 # Process per-page final cleanup callbacks
332 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Final', CallbackPage => '/autohandler' );
334 $HTML::Mason::Commands::m->comp( '/Elements/Footer', %$ARGS );
339 delete $HTML::Mason::Commands::session{'CurrentUser'};
343 if ( $HTML::Mason::Commands::session{CurrentUser} && $HTML::Mason::Commands::session{'CurrentUser'}->id ) {
351 =head2 LoginError ERROR
353 Pushes a login error into the Actions session store and returns the hash key.
359 my $key = Digest::MD5::md5_hex( rand(1024) );
360 push @{ $HTML::Mason::Commands::session{"Actions"}->{$key} ||= [] }, $new;
361 $HTML::Mason::Commands::session{'i'}++;
365 =head2 SetNextPage ARGSRef [PATH]
367 Intuits and stashes the next page in the sesssion hash. If PATH is
368 specified, uses that instead of the value of L<IntuitNextPage()>. Returns
375 my $next = $_[0] ? $_[0] : IntuitNextPage();
376 my $hash = Digest::MD5::md5_hex($next . $$ . rand(1024));
377 my $page = { url => $next };
379 # If an explicit URL was passed and we didn't IntuitNextPage, then
380 # IsPossibleCSRF below is almost certainly unrelated to the actual
381 # destination. Currently explicit next pages aren't used in RT, but the
383 if (not $_[0] and RT->Config->Get("RestrictReferrer")) {
384 # This isn't really CSRF, but the CSRF heuristics are useful for catching
385 # requests which may have unintended side-effects.
386 my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS);
389 "Marking original destination as having side-effects before redirecting for login.\n"
391 ."Reason: " . HTML::Mason::Commands::loc($msg, @loc)
393 $page->{'HasSideEffects'} = [$msg, @loc];
397 $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $page;
398 $HTML::Mason::Commands::session{'i'}++;
402 =head2 FetchNextPage HASHKEY
404 Returns the stashed next page hashref for the given hash.
409 my $hash = shift || "";
410 return $HTML::Mason::Commands::session{'NextPage'}->{$hash};
413 =head2 RemoveNextPage HASHKEY
415 Removes the stashed next page for the given hash and returns it.
420 my $hash = shift || "";
421 return delete $HTML::Mason::Commands::session{'NextPage'}->{$hash};
424 =head2 TangentForLogin ARGSRef [HASH]
426 Redirects to C</NoAuth/Login.html>, setting the value of L<IntuitNextPage> as
427 the next page. Takes a hashref of request %ARGS as the first parameter.
428 Optionally takes all other parameters as a hash which is dumped into query
433 sub TangentForLogin {
435 my $hash = SetNextPage($ARGS);
436 my %query = (@_, next => $hash);
437 my $login = RT->Config->Get('WebURL') . 'NoAuth/Login.html?';
438 $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query);
442 =head2 TangentForLoginWithError ERROR
444 Localizes the passed error message, stashes it with L<LoginError> and then
445 calls L<TangentForLogin> with the appropriate results key.
449 sub TangentForLoginWithError {
451 my $key = LoginError(HTML::Mason::Commands::loc(@_));
452 TangentForLogin( $ARGS, results => $key );
455 =head2 IntuitNextPage
457 Attempt to figure out the path to which we should return the user after a
458 tangent. The current request URL is used, or failing that, the C<WebURL>
459 configuration variable.
466 # This includes any query parameters. Redirect will take care of making
467 # it an absolute URL.
468 if ($ENV{'REQUEST_URI'}) {
469 $req_uri = $ENV{'REQUEST_URI'};
471 # collapse multiple leading slashes so the first part doesn't look like
472 # a hostname of a schema-less URI
473 $req_uri =~ s{^/+}{/};
476 my $next = defined $req_uri ? $req_uri : RT->Config->Get('WebURL');
479 my $uri = URI->new($next);
481 # You get undef scheme with a relative uri like "/Search/Build.html"
482 unless (!defined($uri->scheme) || $uri->scheme eq 'http' || $uri->scheme eq 'https') {
483 $next = RT->Config->Get('WebURL');
486 # Make sure we're logging in to the same domain
487 # You can get an undef authority with a relative uri like "index.html"
488 my $uri_base_url = URI->new(RT->Config->Get('WebBaseURL'));
489 unless (!defined($uri->authority) || $uri->authority eq $uri_base_url->authority) {
490 $next = RT->Config->Get('WebURL');
496 =head2 MaybeShowInstallModePage
498 This function, called exclusively by RT's autohandler, dispatches
499 a request to RT's Installation workflow, only if Install Mode is enabled in the configuration file.
501 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
505 sub MaybeShowInstallModePage {
506 return unless RT->InstallMode;
508 my $m = $HTML::Mason::Commands::m;
509 if ( $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex') ) {
511 } elsif ( $m->request_comp->path !~ m{^(/+)Install/} ) {
512 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "Install/index.html" );
519 =head2 MaybeShowNoAuthPage \%ARGS
521 This function, called exclusively by RT's autohandler, dispatches
522 a request to the page a user requested (but only if it matches the "noauth" regex.
524 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
528 sub MaybeShowNoAuthPage {
531 my $m = $HTML::Mason::Commands::m;
533 return unless $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex');
535 # Don't show the login page to logged in users
536 Redirect(RT->Config->Get('WebURL'))
537 if $m->base_comp->path eq '/NoAuth/Login.html' and _UserLoggedIn();
539 # If it's a noauth file, don't ask for auth.
540 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
544 =head2 MaybeRejectPrivateComponentRequest
546 This function will reject calls to private components, like those under
547 C</Elements>. If the requested path is a private component then we will
548 abort with a C<403> error.
552 sub MaybeRejectPrivateComponentRequest {
553 my $m = $HTML::Mason::Commands::m;
554 my $path = $m->request_comp->path;
556 # We do not check for dhandler here, because requesting our dhandlers
557 # directly is okay. Mason will invoke the dhandler with a dhandler_arg of
563 _elements | # mobile UI
565 autohandler | # requesting this directly is suspicious
566 l (_unsafe)? ) # loc component
567 ( $ | / ) # trailing slash or end of path
576 $HTML::Mason::Commands::m->notes('menu', RT::Interface::Web::Menu->new());
577 $HTML::Mason::Commands::m->notes('page-menu', RT::Interface::Web::Menu->new());
578 $HTML::Mason::Commands::m->notes('page-widgets', RT::Interface::Web::Menu->new());
583 =head2 ShowRequestedPage \%ARGS
585 This function, called exclusively by RT's autohandler, dispatches
586 a request to the page a user requested (making sure that unpriviled users
587 can only see self-service pages.
591 sub ShowRequestedPage {
594 my $m = $HTML::Mason::Commands::m;
596 # Ensure that the cookie that we send is up-to-date, in case the
597 # session-id has been modified in any way
600 # precache all system level rights for the current user
601 $HTML::Mason::Commands::session{CurrentUser}->PrincipalObj->HasRights( Object => RT->System );
603 # If the user isn't privileged, they can only see SelfService
604 unless ( $HTML::Mason::Commands::session{'CurrentUser'}->Privileged ) {
606 # if the user is trying to access a ticket, redirect them
607 if ( $m->request_comp->path =~ m{^(/+)Ticket/Display.html} && $ARGS->{'id'} ) {
608 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/Display.html?id=" . $ARGS->{'id'} );
611 # otherwise, drop the user at the SelfService default page
612 elsif ( $m->base_comp->path !~ RT->Config->Get('SelfServiceRegex') ) {
613 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/" );
616 # if user is in SelfService dir let him do anything
618 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
621 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
626 sub AttemptExternalAuth {
629 return unless ( RT->Config->Get('WebExternalAuth') );
631 my $user = $ARGS->{user};
632 my $m = $HTML::Mason::Commands::m;
634 # If RT is configured for external auth, let's go through and get REMOTE_USER
636 # do we actually have a REMOTE_USER equivlent?
637 if ( RT::Interface::Web::WebCanonicalizeInfo() ) {
638 my $orig_user = $user;
640 $user = RT::Interface::Web::WebCanonicalizeInfo();
641 my $load_method = RT->Config->Get('WebExternalGecos') ? 'LoadByGecos' : 'Load';
643 if ( $^O eq 'MSWin32' and RT->Config->Get('WebExternalGecos') ) {
644 my $NodeName = Win32::NodeName();
645 $user =~ s/^\Q$NodeName\E\\//i;
648 my $next = RemoveNextPage($ARGS->{'next'});
649 $next = $next->{'url'} if ref $next;
650 InstantiateNewSession() unless _UserLoggedIn;
651 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
652 $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user);
654 if ( RT->Config->Get('WebExternalAuto') and not _UserLoggedIn() ) {
656 # Create users on-the-fly
657 my $UserObj = RT::User->new(RT->SystemUser);
658 my ( $val, $msg ) = $UserObj->Create(
659 %{ ref RT->Config->Get('AutoCreate') ? RT->Config->Get('AutoCreate') : {} },
666 # now get user specific information, to better create our user.
667 my $new_user_info = RT::Interface::Web::WebExternalAutoInfo($user);
669 # set the attributes that have been defined.
670 foreach my $attribute ( $UserObj->WritableAttributes ) {
672 Attribute => $attribute,
674 UserInfo => $new_user_info,
675 CallbackName => 'NewUser',
676 CallbackPage => '/autohandler'
678 my $method = "Set$attribute";
679 $UserObj->$method( $new_user_info->{$attribute} ) if defined $new_user_info->{$attribute};
681 $HTML::Mason::Commands::session{'CurrentUser'}->Load($user);
684 # we failed to successfully create the user. abort abort abort.
685 delete $HTML::Mason::Commands::session{'CurrentUser'};
687 if (RT->Config->Get('WebFallbackToInternalAuth')) {
688 TangentForLoginWithError($ARGS, 'Cannot create user: [_1]', $msg);
695 if ( _UserLoggedIn() ) {
696 $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' );
697 # It is possible that we did a redirect to the login page,
698 # if the external auth allows lack of auth through with no
699 # REMOTE_USER set, instead of forcing a "permission
700 # denied" message. Honor the $next.
701 Redirect($next) if $next;
702 # Unlike AttemptPasswordAuthentication below, we do not
703 # force a redirect to / if $next is not set -- otherwise,
704 # straight-up external auth would always redirect to /
705 # when you first hit it.
707 delete $HTML::Mason::Commands::session{'CurrentUser'};
710 unless ( RT->Config->Get('WebFallbackToInternalAuth') ) {
711 TangentForLoginWithError($ARGS, 'You are not an authorized user');
714 } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) {
715 unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) {
716 # XXX unreachable due to prior defaulting in HandleRequest (check c34d108)
717 TangentForLoginWithError($ARGS, 'You are not an authorized user');
721 # WebExternalAuth is set, but we don't have a REMOTE_USER. abort
722 # XXX: we must return AUTH_REQUIRED status or we fallback to
723 # internal auth here too.
724 delete $HTML::Mason::Commands::session{'CurrentUser'}
725 if defined $HTML::Mason::Commands::session{'CurrentUser'};
729 sub AttemptPasswordAuthentication {
731 return unless defined $ARGS->{user} && defined $ARGS->{pass};
733 my $user_obj = RT::CurrentUser->new();
734 $user_obj->Load( $ARGS->{user} );
736 my $m = $HTML::Mason::Commands::m;
738 unless ( $user_obj->id && $user_obj->IsPassword( $ARGS->{pass} ) ) {
739 $RT::Logger->error("FAILED LOGIN for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
740 $m->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' );
741 return (0, HTML::Mason::Commands::loc('Your username or password is incorrect'));
744 $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
746 # It's important to nab the next page from the session before we blow
748 my $next = RemoveNextPage($ARGS->{'next'});
749 $next = $next->{'url'} if ref $next;
751 InstantiateNewSession();
752 $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
754 $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' );
756 # Really the only time we don't want to redirect here is if we were
757 # passed user and pass as query params in the URL.
761 elsif ($ARGS->{'next'}) {
762 # Invalid hash, but still wants to go somewhere, take them to /
763 Redirect(RT->Config->Get('WebURL'));
766 return (1, HTML::Mason::Commands::loc('Logged in'));
770 =head2 LoadSessionFromCookie
772 Load or setup a session cookie for the current user.
776 sub _SessionCookieName {
777 my $cookiename = "RT_SID_" . RT->Config->Get('rtname');
778 $cookiename .= "." . $ENV{'SERVER_PORT'} if $ENV{'SERVER_PORT'};
782 sub LoadSessionFromCookie {
784 my %cookies = CGI::Cookie->fetch;
785 my $cookiename = _SessionCookieName();
786 my $SessionCookie = ( $cookies{$cookiename} ? $cookies{$cookiename}->value : undef );
787 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', $SessionCookie;
788 unless ( $SessionCookie && $HTML::Mason::Commands::session{'_session_id'} eq $SessionCookie ) {
789 undef $cookies{$cookiename};
791 if ( int RT->Config->Get('AutoLogoff') ) {
792 my $now = int( time / 60 );
793 my $last_update = $HTML::Mason::Commands::session{'_session_last_update'} || 0;
795 if ( $last_update && ( $now - $last_update - RT->Config->Get('AutoLogoff') ) > 0 ) {
796 InstantiateNewSession();
799 # save session on each request when AutoLogoff is turned on
800 $HTML::Mason::Commands::session{'_session_last_update'} = $now if $now != $last_update;
804 sub InstantiateNewSession {
805 tied(%HTML::Mason::Commands::session)->delete if tied(%HTML::Mason::Commands::session);
806 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', undef;
810 sub SendSessionCookie {
811 my $cookie = CGI::Cookie->new(
812 -name => _SessionCookieName(),
813 -value => $HTML::Mason::Commands::session{_session_id},
814 -path => RT->Config->Get('WebPath'),
815 -secure => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 ),
816 -httponly => ( RT->Config->Get('WebHttpOnlyCookies') ? 1 : 0 ),
819 $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string;
824 This routine ells the current user's browser to redirect to URL.
825 Additionally, it unties the user's currently active session, helping to avoid
826 A bug in Apache::Session 1.81 and earlier which clobbers sessions if we try to use
827 a cached DBI statement handle twice at the same time.
832 my $redir_to = shift;
833 untie $HTML::Mason::Commands::session;
834 my $uri = URI->new($redir_to);
835 my $server_uri = URI->new( RT->Config->Get('WebURL') );
837 # Make relative URIs absolute from the server host and scheme
838 $uri->scheme($server_uri->scheme) if not defined $uri->scheme;
839 if (not defined $uri->host) {
840 $uri->host($server_uri->host);
841 $uri->port($server_uri->port);
844 # If the user is coming in via a non-canonical
845 # hostname, don't redirect them to the canonical host,
846 # it will just upset them (and invalidate their credentials)
847 # don't do this if $RT::CanonicalizeRedirectURLs is true
848 if ( !RT->Config->Get('CanonicalizeRedirectURLs')
849 && $uri->host eq $server_uri->host
850 && $uri->port eq $server_uri->port )
852 if ( defined $ENV{HTTPS} and $ENV{'HTTPS'} eq 'on' ) {
853 $uri->scheme('https');
855 $uri->scheme('http');
858 # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST
859 $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'});
860 $uri->port( $ENV{'SERVER_PORT'} );
863 # not sure why, but on some systems without this call mason doesn't
864 # set status to 302, but 200 instead and people see blank pages
865 $HTML::Mason::Commands::r->status(302);
867 # Perlbal expects a status message, but Mason's default redirect status
868 # doesn't provide one. See also rt.cpan.org #36689.
869 $HTML::Mason::Commands::m->redirect( $uri->canonical, "302 Found" );
871 $HTML::Mason::Commands::m->abort;
874 =head2 StaticFileHeaders
876 Send the browser a few headers to try to get it to (somewhat agressively)
877 cache RT's static Javascript and CSS files.
879 This routine could really use _accurate_ heuristics. (XXX TODO)
883 sub StaticFileHeaders {
884 my $date = RT::Date->new(RT->SystemUser);
887 $HTML::Mason::Commands::r->headers_out->{'Cache-Control'} = 'max-age=259200, public';
889 # remove any cookie headers -- if it is cached publicly, it
890 # shouldn't include anyone's cookie!
891 delete $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'};
893 # Expire things in a month.
894 $date->Set( Value => time + 30 * 24 * 60 * 60 );
895 $HTML::Mason::Commands::r->headers_out->{'Expires'} = $date->RFC2616;
897 # if we set 'Last-Modified' then browser request a comp using 'If-Modified-Since'
898 # request, but we don't handle it and generate full reply again
899 # Last modified at server start time
900 # $date->Set( Value => $^T );
901 # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616;
904 =head2 ComponentPathIsSafe PATH
906 Takes C<PATH> and returns a boolean indicating that the user-specified partial
907 component path is safe.
909 Currently "safe" means that the path does not start with a dot (C<.>) and does
910 not contain a slash-dot C</.>.
914 sub ComponentPathIsSafe {
917 return $path !~ m{(?:^|/)\.};
922 Takes a C<< Path => path >> and returns a boolean indicating that
923 the path is safely within RT's control or not. The path I<must> be
926 This function does not consult the filesystem at all; it is merely
927 a logical sanity checking of the path. This explicitly does not handle
928 symlinks; if you have symlinks in RT's webroot pointing outside of it,
929 then we assume you know what you are doing.
936 my $path = $args{Path};
938 # Get File::Spec to clean up extra /s, ./, etc
939 my $cleaned_up = File::Spec->canonpath($path);
941 if (!defined($cleaned_up)) {
942 $RT::Logger->info("Rejecting path that canonpath doesn't understand: $path");
946 # Forbid too many ..s. We can't just sum then check because
947 # "../foo/bar/baz" should be illegal even though it has more
948 # downdirs than updirs. So as soon as we get a negative score
949 # (which means "breaking out" of the top level) we reject the path.
951 my @components = split '/', $cleaned_up;
953 for my $component (@components) {
954 if ($component eq '..') {
957 $RT::Logger->info("Rejecting unsafe path: $path");
961 elsif ($component eq '.' || $component eq '') {
962 # these two have no effect on $score
972 =head2 SendStaticFile
974 Takes a File => path and a Type => Content-type
976 If Type isn't provided and File is an image, it will
977 figure out a sane Content-type, otherwise it will
978 send application/octet-stream
980 Will set caching headers using StaticFileHeaders
987 my $file = $args{File};
988 my $type = $args{Type};
989 my $relfile = $args{RelativeFile};
991 if (defined($relfile) && !$self->PathIsSafe(Path => $relfile)) {
992 $HTML::Mason::Commands::r->status(400);
993 $HTML::Mason::Commands::m->abort;
996 $self->StaticFileHeaders();
999 if ( $file =~ /\.(gif|png|jpe?g)$/i ) {
1001 $type =~ s/jpg/jpeg/gi;
1003 $type ||= "application/octet-stream";
1005 $HTML::Mason::Commands::r->content_type($type);
1006 open( my $fh, '<', $file ) or die "couldn't open file: $!";
1010 $HTML::Mason::Commands::m->out($_) while (<$fh>);
1011 $HTML::Mason::Commands::m->flush_buffer;
1022 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'}) {
1033 my $content = $args{Content};
1034 return '' unless $content;
1036 # Make the content have no 'weird' newlines in it
1037 $content =~ s/\r+\n/\n/g;
1039 my $return_content = $content;
1041 my $html = $args{ContentType} && $args{ContentType} eq "text/html";
1042 my $sigonly = $args{StripSignature};
1044 # massage content to easily detect if there's any real content
1045 $content =~ s/\s+//g; # yes! remove all the spaces
1047 # remove html version of spaces and newlines
1048 $content =~ s! !!g;
1049 $content =~ s!<br/?>!!g;
1052 # Filter empty content when type is text/html
1053 return '' if $html && $content !~ /\S/;
1055 # If we aren't supposed to strip the sig, just bail now.
1056 return $return_content unless $sigonly;
1058 # Find the signature
1059 my $sig = $args{'CurrentUser'}->UserObj->Signature || '';
1062 # Check for plaintext sig
1063 return '' if not $html and $content =~ /^(--)?\Q$sig\E$/;
1065 # Check for html-formatted sig; we don't use EscapeUTF8 here
1066 # because we want to precisely match the escapting that FCKEditor
1068 $sig =~ s/&/&/g;
1071 $sig =~ s/"/"/g;
1072 $sig =~ s/'/'/g;
1073 return '' if $html and $content =~ m{^(?:<p>)?(--)?\Q$sig\E(?:</p>)?$}s;
1076 return $return_content;
1084 # if they've passed multiple values, they'll be an array. if they've
1085 # passed just one, a scalar whatever they are, mark them as utf8
1088 ? Encode::is_utf8($_)
1090 : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ )
1091 : ( $type eq 'ARRAY' )
1092 ? [ map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) }
1094 : ( $type eq 'HASH' )
1095 ? { map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) }
1101 sub PreprocessTimeUpdates {
1104 # Later in the code we use
1105 # $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %ARGS );
1106 # instead of $m->call_next to avoid problems with UTF8 keys in arguments.
1107 # The call_next method pass through original arguments and if you have
1108 # an argument with unicode key then in a next component you'll get two
1109 # records in the args hash: one with key without UTF8 flag and another
1110 # with the flag, which may result into errors. "{ base_comp => $m->request_comp }"
1111 # is copied from mason's source to get the same results as we get from
1112 # call_next method, this feature is not documented, so we just leave it
1113 # here to avoid possible side effects.
1115 # This code canonicalizes time inputs in hours into minutes
1116 foreach my $field ( keys %$ARGS ) {
1117 next unless $field =~ /^(.*)-TimeUnits$/i && $ARGS->{$1};
1119 $ARGS->{$local} =~ s{\b (?: (\d+) \s+ )? (\d+)/(\d+) \b}
1120 {($1 || 0) + $3 ? $2 / $3 : 0}xe;
1121 if ( $ARGS->{$field} && $ARGS->{$field} =~ /hours/i ) {
1122 $ARGS->{$local} *= 60;
1124 delete $ARGS->{$field};
1129 sub MaybeEnableSQLStatementLog {
1131 my $log_sql_statements = RT->Config->Get('StatementLog');
1133 if ($log_sql_statements) {
1134 $RT::Handle->ClearSQLStatementLog;
1135 $RT::Handle->LogSQLStatements(1);
1140 sub LogRecordedSQLStatements {
1143 my $log_sql_statements = RT->Config->Get('StatementLog');
1145 return unless ($log_sql_statements);
1147 my @log = $RT::Handle->SQLStatementLog;
1148 $RT::Handle->ClearSQLStatementLog;
1150 $RT::Handle->AddRequestToHistory({
1151 %{ $args{RequestData} },
1155 for my $stmt (@log) {
1156 my ( $time, $sql, $bind, $duration ) = @{$stmt};
1166 level => $log_sql_statements,
1168 . sprintf( "%.6f", $duration )
1170 . ( @bind ? " [ bound values: @{[map{ defined $_ ? qq|'$_'| : 'undef'} @bind]} ]" : "" )
1176 my $_has_validated_web_config = 0;
1177 sub ValidateWebConfig {
1180 # do this once per server instance, not once per request
1181 return if $_has_validated_web_config;
1182 $_has_validated_web_config = 1;
1184 if (!$ENV{'rt.explicit_port'} && $ENV{SERVER_PORT} != RT->Config->Get('WebPort')) {
1185 $RT::Logger->warn("The actual SERVER_PORT ($ENV{SERVER_PORT}) does NOT match the configured WebPort ($RT::WebPort). Perhaps you should Set(\$WebPort, $ENV{SERVER_PORT}); in RT_SiteConfig.pm, otherwise your internal links may be broken.");
1188 if ($ENV{HTTP_HOST}) {
1189 # match "example.com" or "example.com:80"
1190 my ($host) = $ENV{HTTP_HOST} =~ /^(.*?)(:\d+)?$/;
1192 if ($host ne RT->Config->Get('WebDomain')) {
1193 $RT::Logger->warn("The actual HTTP_HOST ($host) does NOT match the configured WebDomain ($RT::WebDomain). Perhaps you should Set(\$WebDomain, '$host'); in RT_SiteConfig.pm, otherwise your internal links may be broken.");
1197 if ($ENV{SERVER_NAME} ne RT->Config->Get('WebDomain')) {
1198 $RT::Logger->warn("The actual SERVER_NAME ($ENV{SERVER_NAME}) does NOT match the configured WebDomain ($RT::WebDomain). Perhaps you should Set(\$WebDomain, '$ENV{SERVER_NAME}'); in RT_SiteConfig.pm, otherwise your internal links may be broken.");
1202 if ($ENV{SCRIPT_NAME} ne RT->Config->Get('WebPath')) {
1203 $RT::Logger->warn("The actual SCRIPT_NAME ($ENV{SCRIPT_NAME}) does NOT match the configured WebPath ($RT::WebPath). Perhaps you should Set(\$WebPath, '$ENV{SCRIPT_NAME}'); in RT_SiteConfig.pm, otherwise your internal links may be broken.");
1207 sub ComponentRoots {
1209 my %args = ( Names => 0, @_ );
1211 if (defined $HTML::Mason::Commands::m) {
1212 @roots = $HTML::Mason::Commands::m->interp->comp_root_array;
1215 [ local => $RT::MasonLocalComponentRoot ],
1216 (map {[ "plugin-".$_->Name => $_->ComponentRoot ]} @{RT->Plugins}),
1217 [ standard => $RT::MasonComponentRoot ]
1220 @roots = map { $_->[1] } @roots unless $args{Names};
1224 our %is_whitelisted_component = (
1225 # The RSS feed embeds an auth token in the path, but query
1226 # information for the search. Because it's a straight-up read, in
1227 # addition to embedding its own auth, it's fine.
1228 '/NoAuth/rss/dhandler' => 1,
1230 # While these can be used for denial-of-service against RT
1231 # (construct a very inefficient query and trick lots of users into
1232 # running them against RT) it's incredibly useful to be able to link
1233 # to a search result or bookmark a result page.
1234 '/Search/Results.html' => 1,
1235 '/Search/Simple.html' => 1,
1236 '/m/tickets/search' => 1,
1239 # Components which are blacklisted from automatic, argument-based whitelisting.
1240 # These pages are not idempotent when called with just an id.
1241 our %is_blacklisted_component = (
1242 # Takes only id and toggles bookmark state
1243 '/Helpers/Toggle/TicketBookmark' => 1,
1246 sub IsCompCSRFWhitelisted {
1250 return 1 if $is_whitelisted_component{$comp};
1252 my %args = %{ $ARGS };
1254 # If the user specifies a *correct* user and pass then they are
1255 # golden. This acts on the presumption that external forms may
1256 # hardcode a username and password -- if a malicious attacker knew
1257 # both already, CSRF is the least of your problems.
1258 my $AllowLoginCSRF = not RT->Config->Get('RestrictReferrerLogin');
1259 if ($AllowLoginCSRF and defined($args{user}) and defined($args{pass})) {
1260 my $user_obj = RT::CurrentUser->new();
1261 $user_obj->Load($args{user});
1262 return 1 if $user_obj->id && $user_obj->IsPassword($args{pass});
1268 # Some pages aren't idempotent even with safe args like id; blacklist
1269 # them from the automatic whitelisting below.
1270 return 0 if $is_blacklisted_component{$comp};
1272 # Eliminate arguments that do not indicate an effectful request.
1273 # For example, "id" is acceptable because that is how RT retrieves a
1277 # If they have a valid results= from MaybeRedirectForResults, that's
1279 delete $args{results} if $args{results}
1280 and $HTML::Mason::Commands::session{"Actions"}->{$args{results}};
1282 # The homepage refresh, which uses the Refresh header, doesn't send
1283 # a referer in most browsers; whitelist the one parameter it reloads
1284 # with, HomeRefreshInterval, which is safe
1285 delete $args{HomeRefreshInterval};
1287 # If there are no arguments, then it's likely to be an idempotent
1288 # request, which are not susceptible to CSRF
1294 sub IsRefererCSRFWhitelisted {
1295 my $referer = _NormalizeHost(shift);
1296 my $base_url = _NormalizeHost(RT->Config->Get('WebBaseURL'));
1297 $base_url = $base_url->host_port;
1300 for my $config ( $base_url, RT->Config->Get('ReferrerWhitelist') ) {
1301 push @$configs,$config;
1303 my $host_port = $referer->host_port;
1304 if ($config =~ /\*/) {
1305 # Turn a literal * into a domain component or partial component match.
1306 # Refer to http://tools.ietf.org/html/rfc2818#page-5
1307 my $regex = join "[a-zA-Z0-9\-]*",
1308 map { quotemeta($_) }
1309 split /\*/, $config;
1311 return 1 if $host_port =~ /^$regex$/i;
1313 return 1 if $host_port eq $config;
1317 return (0,$referer,$configs);
1320 =head3 _NormalizeHost
1322 Takes a URI and creates a URI object that's been normalized
1323 to handle common problems such as localhost vs 127.0.0.1
1327 sub _NormalizeHost {
1329 my $uri= URI->new(shift);
1330 $uri->host('127.0.0.1') if $uri->host eq 'localhost';
1336 sub IsPossibleCSRF {
1339 # If first request on this session is to a REST endpoint, then
1340 # whitelist the REST endpoints -- and explicitly deny non-REST
1341 # endpoints. We do this because using a REST cookie in a browser
1342 # would open the user to CSRF attacks to the REST endpoints.
1343 my $path = $HTML::Mason::Commands::r->path_info;
1344 $HTML::Mason::Commands::session{'REST'} = $path =~ m{^/+REST/\d+\.\d+(/|$)}
1345 unless defined $HTML::Mason::Commands::session{'REST'};
1347 if ($HTML::Mason::Commands::session{'REST'}) {
1348 return 0 if $path =~ m{^/+REST/\d+\.\d+(/|$)};
1350 This login session belongs to a REST client, and cannot be used to
1351 access non-REST interfaces of RT for security reasons.
1353 my $details = <<EOT;
1354 Please log out and back in to obtain a session for normal browsing. If
1355 you understand the security implications, disabling RT's CSRF protection
1356 will remove this restriction.
1359 HTML::Mason::Commands::Abort( $why, Details => $details );
1362 return 0 if IsCompCSRFWhitelisted(
1363 $HTML::Mason::Commands::m->request_comp->path,
1367 # if there is no Referer header then assume the worst
1369 "your browser did not supply a Referrer header", # loc
1370 ) if !$ENV{HTTP_REFERER};
1372 my ($whitelisted, $browser, $configs) = IsRefererCSRFWhitelisted($ENV{HTTP_REFERER});
1373 return 0 if $whitelisted;
1375 if ( @$configs > 1 ) {
1377 "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2]) or whitelisted hosts ([_3])", # loc
1378 $browser->host_port,
1380 join(', ', @$configs) );
1384 "the Referrer header supplied by your browser ([_1]) is not allowed by RT's configured hostname ([_2])", # loc
1385 $browser->host_port,
1389 sub ExpandCSRFToken {
1392 my $token = delete $ARGS->{CSRF_Token};
1393 return unless $token;
1395 my $data = $HTML::Mason::Commands::session{'CSRF'}{$token};
1396 return unless $data;
1397 return unless $data->{path} eq $HTML::Mason::Commands::r->path_info;
1399 my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj;
1400 return unless $user->ValidateAuthString( $data->{auth}, $token );
1402 %{$ARGS} = %{$data->{args}};
1403 $HTML::Mason::Commands::DECODED_ARGS = $ARGS;
1405 # We explicitly stored file attachments with the request, but not in
1406 # the session yet, as that would itself be an attack. Put them into
1407 # the session now, so they'll be visible.
1408 if ($data->{attach}) {
1409 my $filename = $data->{attach}{filename};
1410 my $mime = $data->{attach}{mime};
1411 $HTML::Mason::Commands::session{'Attachments'}{$filename}
1418 sub StoreRequestToken {
1421 my $token = Digest::MD5::md5_hex(time . {} . $$ . rand(1024));
1422 my $user = $HTML::Mason::Commands::session{'CurrentUser'}->UserObj;
1424 auth => $user->GenerateAuthString( $token ),
1425 path => $HTML::Mason::Commands::r->path_info,
1428 if ($ARGS->{Attach}) {
1429 my $attachment = HTML::Mason::Commands::MakeMIMEEntity( AttachmentFieldName => 'Attach' );
1430 my $file_path = delete $ARGS->{'Attach'};
1432 filename => Encode::decode_utf8("$file_path"),
1433 mime => $attachment,
1437 $HTML::Mason::Commands::session{'CSRF'}->{$token} = $data;
1438 $HTML::Mason::Commands::session{'i'}++;
1442 sub MaybeShowInterstitialCSRFPage {
1445 return unless RT->Config->Get('RestrictReferrer');
1447 # Deal with the form token provided by the interstitial, which lets
1448 # browsers which never set referer headers still use RT, if
1449 # painfully. This blows values into ARGS
1450 return if ExpandCSRFToken($ARGS);
1452 my ($is_csrf, $msg, @loc) = IsPossibleCSRF($ARGS);
1453 return if !$is_csrf;
1455 $RT::Logger->notice("Possible CSRF: ".RT::CurrentUser->new->loc($msg, @loc));
1457 my $token = StoreRequestToken($ARGS);
1458 $HTML::Mason::Commands::m->comp(
1460 OriginalURL => RT->Config->Get('WebPath') . $HTML::Mason::Commands::r->path_info,
1461 Reason => HTML::Mason::Commands::loc( $msg, @loc ),
1464 # Calls abort, never gets here
1467 our @POTENTIAL_PAGE_ACTIONS = (
1468 qr'/Ticket/Create.html' => "create a ticket", # loc
1469 qr'/Ticket/' => "update a ticket", # loc
1470 qr'/Admin/' => "modify RT's configuration", # loc
1471 qr'/Approval/' => "update an approval", # loc
1472 qr'/Articles/' => "update an article", # loc
1473 qr'/Dashboards/' => "modify a dashboard", # loc
1474 qr'/m/ticket/' => "update a ticket", # loc
1475 qr'Prefs' => "modify your preferences", # loc
1476 qr'/Search/' => "modify or access a search", # loc
1477 qr'/SelfService/Create' => "create a ticket", # loc
1478 qr'/SelfService/' => "update a ticket", # loc
1481 sub PotentialPageAction {
1483 my @potentials = @POTENTIAL_PAGE_ACTIONS;
1484 while (my ($pattern, $result) = splice @potentials, 0, 2) {
1485 return HTML::Mason::Commands::loc($result)
1486 if $page =~ $pattern;
1491 package HTML::Mason::Commands;
1493 use vars qw/$r $m %session/;
1496 return $HTML::Mason::Commands::m->notes('menu');
1500 return $HTML::Mason::Commands::m->notes('page-menu');
1504 return $HTML::Mason::Commands::m->notes('page-widgets');
1511 loc is a nice clean global routine which calls $session{'CurrentUser'}->loc()
1512 with whatever it's called with. If there is no $session{'CurrentUser'},
1513 it creates a temporary user, so we have something to get a localisation handle
1520 if ( $session{'CurrentUser'}
1521 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1523 return ( $session{'CurrentUser'}->loc(@_) );
1526 RT::CurrentUser->new();
1530 return ( $u->loc(@_) );
1533 # pathetic case -- SystemUser is gone.
1540 =head2 loc_fuzzy STRING
1542 loc_fuzzy is for handling localizations of messages that may already
1543 contain interpolated variables, typically returned from libraries
1544 outside RT's control. It takes the message string and extracts the
1545 variable array automatically by matching against the candidate entries
1546 inside the lexicon file.
1553 if ( $session{'CurrentUser'}
1554 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1556 return ( $session{'CurrentUser'}->loc_fuzzy($msg) );
1558 my $u = RT::CurrentUser->new( RT->SystemUser->Id );
1559 return ( $u->loc_fuzzy($msg) );
1564 # Error - calls Error and aborts
1569 if ( $session{'ErrorDocument'}
1570 && $session{'ErrorDocumentType'} )
1572 $r->content_type( $session{'ErrorDocumentType'} );
1573 $m->comp( $session{'ErrorDocument'}, Why => $why, %args );
1576 $m->comp( "/Elements/Error", Why => $why, %args );
1581 sub MaybeRedirectForResults {
1583 Path => $HTML::Mason::Commands::m->request_comp->path,
1590 my $has_actions = $args{'Actions'} && grep( defined, @{ $args{'Actions'} } );
1591 return unless $has_actions || $args{'Force'};
1593 my %arguments = %{ $args{'Arguments'} };
1595 if ( $has_actions ) {
1596 my $key = Digest::MD5::md5_hex( rand(1024) );
1597 push @{ $session{"Actions"}{ $key } ||= [] }, @{ $args{'Actions'} };
1599 $arguments{'results'} = $key;
1602 $args{'Path'} =~ s!^/+!!;
1603 my $url = RT->Config->Get('WebURL') . $args{Path};
1605 if ( keys %arguments ) {
1606 $url .= '?'. $m->comp( '/Elements/QueryString', %arguments );
1608 if ( $args{'Anchor'} ) {
1609 $url .= "#". $args{'Anchor'};
1611 return RT::Interface::Web::Redirect($url);
1614 =head2 MaybeRedirectToApproval Path => 'path', Whitelist => REGEX, ARGSRef => HASHREF
1616 If the ticket specified by C<< $ARGSRef->{id} >> is an approval ticket,
1617 redirect to the approvals display page, preserving any arguments.
1619 C<Path>s matching C<Whitelist> are let through.
1621 This is a no-op if the C<ForceApprovalsView> option isn't enabled.
1625 sub MaybeRedirectToApproval {
1627 Path => $HTML::Mason::Commands::m->request_comp->path,
1633 return unless $ENV{REQUEST_METHOD} eq 'GET';
1635 my $id = $args{ARGSRef}->{id};
1638 and RT->Config->Get('ForceApprovalsView')
1639 and not $args{Path} =~ /$args{Whitelist}/)
1641 my $ticket = RT::Ticket->new( $session{'CurrentUser'} );
1644 if ($ticket and $ticket->id and lc($ticket->Type) eq 'approval') {
1645 MaybeRedirectForResults(
1646 Path => "/Approvals/Display.html",
1648 Anchor => $args{ARGSRef}->{Anchor},
1649 Arguments => $args{ARGSRef},
1655 =head2 CreateTicket ARGS
1657 Create a new ticket, using Mason's %ARGS. returns @results.
1666 my $Ticket = RT::Ticket->new( $session{'CurrentUser'} );
1668 my $Queue = RT::Queue->new( $session{'CurrentUser'} );
1669 unless ( $Queue->Load( $ARGS{'Queue'} ) ) {
1670 Abort('Queue not found');
1673 unless ( $Queue->CurrentUserHasRight('CreateTicket') ) {
1674 Abort('You have no permission to create tickets in that queue.');
1678 if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) {
1679 $due = RT::Date->new( $session{'CurrentUser'} );
1680 $due->Set( Format => 'unknown', Value => $ARGS{'Due'} );
1683 if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) {
1684 $starts = RT::Date->new( $session{'CurrentUser'} );
1685 $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} );
1688 my $sigless = RT::Interface::Web::StripContent(
1689 Content => $ARGS{Content},
1690 ContentType => $ARGS{ContentType},
1691 StripSignature => 1,
1692 CurrentUser => $session{'CurrentUser'},
1695 my $MIMEObj = MakeMIMEEntity(
1696 Subject => $ARGS{'Subject'},
1697 From => $ARGS{'From'},
1700 Type => $ARGS{'ContentType'},
1703 if ( $ARGS{'Attachments'} ) {
1704 my $rv = $MIMEObj->make_multipart;
1705 $RT::Logger->error("Couldn't make multipart message")
1706 if !$rv || $rv !~ /^(?:DONE|ALREADY)$/;
1708 foreach ( values %{ $ARGS{'Attachments'} } ) {
1710 $RT::Logger->error("Couldn't add empty attachemnt");
1713 $MIMEObj->add_part($_);
1717 for my $argument (qw(Encrypt Sign)) {
1718 $MIMEObj->head->replace( "X-RT-$argument" => $ARGS{$argument} ? 1 : 0 );
1722 Type => $ARGS{'Type'} || 'ticket',
1723 Queue => $ARGS{'Queue'},
1724 Owner => $ARGS{'Owner'},
1727 Requestor => $ARGS{'Requestors'},
1729 AdminCc => $ARGS{'AdminCc'},
1730 InitialPriority => $ARGS{'InitialPriority'},
1731 FinalPriority => $ARGS{'FinalPriority'},
1732 TimeLeft => $ARGS{'TimeLeft'},
1733 TimeEstimated => $ARGS{'TimeEstimated'},
1734 TimeWorked => $ARGS{'TimeWorked'},
1735 Subject => $ARGS{'Subject'},
1736 Status => $ARGS{'Status'},
1737 Due => $due ? $due->ISO : undef,
1738 Starts => $starts ? $starts->ISO : undef,
1743 foreach my $type (qw(Requestor Cc AdminCc)) {
1744 push @txn_squelch, map $_->address, Email::Address->parse( $create_args{$type} )
1745 if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] };
1747 $create_args{TransSquelchMailTo} = \@txn_squelch
1750 if ( $ARGS{'AttachTickets'} ) {
1751 require RT::Action::SendEmail;
1752 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
1753 ref $ARGS{'AttachTickets'}
1754 ? @{ $ARGS{'AttachTickets'} }
1755 : ( $ARGS{'AttachTickets'} ) );
1758 foreach my $arg ( keys %ARGS ) {
1759 next if $arg =~ /-(?:Magic|Category)$/;
1761 if ( $arg =~ /^Object-RT::Transaction--CustomField-/ ) {
1762 $create_args{$arg} = $ARGS{$arg};
1765 # Object-RT::Ticket--CustomField-3-Values
1766 elsif ( $arg =~ /^Object-RT::Ticket--CustomField-(\d+)/ ) {
1769 my $cf = RT::CustomField->new( $session{'CurrentUser'} );
1770 $cf->SetContextObject( $Queue );
1772 unless ( $cf->id ) {
1773 $RT::Logger->error( "Couldn't load custom field #" . $cfid );
1777 if ( $arg =~ /-Upload$/ ) {
1778 $create_args{"CustomField-$cfid"} = _UploadedFile($arg);
1782 my $type = $cf->Type;
1785 if ( ref $ARGS{$arg} eq 'ARRAY' ) {
1786 @values = @{ $ARGS{$arg} };
1787 } elsif ( $type =~ /text/i ) {
1788 @values = ( $ARGS{$arg} );
1790 no warnings 'uninitialized';
1791 @values = split /\r*\n/, $ARGS{$arg};
1793 @values = grep length, map {
1799 grep defined, @values;
1801 $create_args{"CustomField-$cfid"} = \@values;
1805 # turn new link lists into arrays, and pass in the proper arguments
1807 'new-DependsOn' => 'DependsOn',
1808 'DependsOn-new' => 'DependedOnBy',
1809 'new-MemberOf' => 'Parents',
1810 'MemberOf-new' => 'Children',
1811 'new-RefersTo' => 'RefersTo',
1812 'RefersTo-new' => 'ReferredToBy',
1814 foreach my $key ( keys %map ) {
1815 next unless $ARGS{$key};
1816 $create_args{ $map{$key} } = [ grep $_, split ' ', $ARGS{$key} ];
1820 my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args);
1825 push( @Actions, split( "\n", $ErrMsg ) );
1826 unless ( $Ticket->CurrentUserHasRight('ShowTicket') ) {
1827 Abort( "No permission to view newly created ticket #" . $Ticket->id . "." );
1829 return ( $Ticket, @Actions );
1835 =head2 LoadTicket id
1837 Takes a ticket id as its only variable. if it's handed an array, it takes
1840 Returns an RT::Ticket object as the current user.
1847 if ( ref($id) eq "ARRAY" ) {
1852 Abort("No ticket specified");
1855 my $Ticket = RT::Ticket->new( $session{'CurrentUser'} );
1857 unless ( $Ticket->id ) {
1858 Abort("Could not load ticket $id");
1865 =head2 ProcessUpdateMessage
1867 Takes paramhash with fields ARGSRef, TicketObj and SkipSignatureOnly.
1869 Don't write message if it only contains current user's signature and
1870 SkipSignatureOnly argument is true. Function anyway adds attachments
1871 and updates time worked field even if skips message. The default value
1876 sub ProcessUpdateMessage {
1881 SkipSignatureOnly => 1,
1885 if ( $args{ARGSRef}->{'UpdateAttachments'}
1886 && !keys %{ $args{ARGSRef}->{'UpdateAttachments'} } )
1888 delete $args{ARGSRef}->{'UpdateAttachments'};
1891 # Strip the signature
1892 $args{ARGSRef}->{UpdateContent} = RT::Interface::Web::StripContent(
1893 Content => $args{ARGSRef}->{UpdateContent},
1894 ContentType => $args{ARGSRef}->{UpdateContentType},
1895 StripSignature => $args{SkipSignatureOnly},
1896 CurrentUser => $args{'TicketObj'}->CurrentUser,
1899 # If, after stripping the signature, we have no message, move the
1900 # UpdateTimeWorked into adjusted TimeWorked, so that a later
1901 # ProcessBasics can deal -- then bail out.
1902 if ( not $args{ARGSRef}->{'UpdateAttachments'}
1903 and not length $args{ARGSRef}->{'UpdateContent'} )
1905 if ( $args{ARGSRef}->{'UpdateTimeWorked'} ) {
1906 $args{ARGSRef}->{TimeWorked} = $args{TicketObj}->TimeWorked + delete $args{ARGSRef}->{'UpdateTimeWorked'};
1911 if ( $args{ARGSRef}->{'UpdateSubject'} eq ($args{'TicketObj'}->Subject || '') ) {
1912 $args{ARGSRef}->{'UpdateSubject'} = undef;
1915 my $Message = MakeMIMEEntity(
1916 Subject => $args{ARGSRef}->{'UpdateSubject'},
1917 Body => $args{ARGSRef}->{'UpdateContent'},
1918 Type => $args{ARGSRef}->{'UpdateContentType'},
1921 $Message->head->replace( 'Message-ID' => Encode::encode_utf8(
1922 RT::Interface::Email::GenMessageId( Ticket => $args{'TicketObj'} )
1924 my $old_txn = RT::Transaction->new( $session{'CurrentUser'} );
1925 if ( $args{ARGSRef}->{'QuoteTransaction'} ) {
1926 $old_txn->Load( $args{ARGSRef}->{'QuoteTransaction'} );
1928 $old_txn = $args{TicketObj}->Transactions->First();
1931 if ( my $msg = $old_txn->Message->First ) {
1932 RT::Interface::Email::SetInReplyTo(
1933 Message => $Message,
1938 if ( $args{ARGSRef}->{'UpdateAttachments'} ) {
1939 $Message->make_multipart;
1940 $Message->add_part($_) foreach values %{ $args{ARGSRef}->{'UpdateAttachments'} };
1943 if ( $args{ARGSRef}->{'AttachTickets'} ) {
1944 require RT::Action::SendEmail;
1945 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
1946 ref $args{ARGSRef}->{'AttachTickets'}
1947 ? @{ $args{ARGSRef}->{'AttachTickets'} }
1948 : ( $args{ARGSRef}->{'AttachTickets'} ) );
1951 my %message_args = (
1952 Sign => ( $args{ARGSRef}->{'Sign'} ? 1 : 0 ),
1953 Encrypt => ( $args{ARGSRef}->{'Encrypt'} ? 1 : 0 ),
1954 MIMEObj => $Message,
1955 TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'}
1958 _ProcessUpdateMessageRecipients(
1959 MessageArgs => \%message_args,
1964 if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) {
1965 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment(%message_args);
1966 push( @results, $Description );
1967 $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
1968 } elsif ( $args{ARGSRef}->{'UpdateType'} eq 'response' ) {
1969 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Correspond(%message_args);
1970 push( @results, $Description );
1971 $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
1974 loc("Update type was neither correspondence nor comment.") . " " . loc("Update not recorded.") );
1979 sub _ProcessUpdateMessageRecipients {
1983 MessageArgs => undef,
1987 my $bcc = $args{ARGSRef}->{'UpdateBcc'};
1988 my $cc = $args{ARGSRef}->{'UpdateCc'};
1990 my $message_args = $args{MessageArgs};
1992 $message_args->{CcMessageTo} = $cc;
1993 $message_args->{BccMessageTo} = $bcc;
1996 foreach my $type (qw(Cc AdminCc)) {
1997 if (grep $_ eq $type || $_ eq ( $type . 's' ), @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
1998 push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{$type} );
1999 push @txn_squelch, $args{TicketObj}->$type->MemberEmailAddresses;
2000 push @txn_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses;
2003 if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
2004 push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{Requestor} );
2005 push @txn_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses;
2008 push @txn_squelch, @{$args{ARGSRef}{SquelchMailTo}} if $args{ARGSRef}{SquelchMailTo};
2009 $message_args->{SquelchMailTo} = \@txn_squelch
2012 unless ( $args{'ARGSRef'}->{'UpdateIgnoreAddressCheckboxes'} ) {
2013 foreach my $key ( keys %{ $args{ARGSRef} } ) {
2014 next unless $key =~ /^Update(Cc|Bcc)-(.*)$/;
2016 my $var = ucfirst($1) . 'MessageTo';
2018 if ( $message_args->{$var} ) {
2019 $message_args->{$var} .= ", $value";
2021 $message_args->{$var} = $value;
2029 =head2 MakeMIMEEntity PARAMHASH
2031 Takes a paramhash Subject, Body and AttachmentFieldName.
2033 Also takes Form, Cc and Type as optional paramhash keys.
2035 Returns a MIME::Entity.
2039 sub MakeMIMEEntity {
2041 #TODO document what else this takes.
2047 AttachmentFieldName => undef,
2051 my $Message = MIME::Entity->build(
2052 Type => 'multipart/mixed',
2053 "Message-Id" => Encode::encode_utf8( RT::Interface::Email::GenMessageId ),
2054 map { $_ => Encode::encode_utf8( $args{ $_} ) }
2055 grep defined $args{$_}, qw(Subject From Cc)
2058 if ( defined $args{'Body'} && length $args{'Body'} ) {
2060 # Make the update content have no 'weird' newlines in it
2061 $args{'Body'} =~ s/\r\n/\n/gs;
2064 Type => $args{'Type'} || 'text/plain',
2066 Data => $args{'Body'},
2070 if ( $args{'AttachmentFieldName'} ) {
2072 my $cgi_object = $m->cgi_object;
2073 my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} );
2074 if ( defined $filehandle && length $filehandle ) {
2076 my ( @content, $buffer );
2077 while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) {
2078 push @content, $buffer;
2081 my $uploadinfo = $cgi_object->uploadInfo($filehandle);
2083 my $filename = "$filehandle";
2084 $filename =~ s{^.*[\\/]}{};
2087 Type => $uploadinfo->{'Content-Type'},
2088 Filename => $filename,
2091 if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) {
2092 $Message->head->set( 'Subject' => $filename );
2095 # Attachment parts really shouldn't get a Message-ID
2096 $Message->head->delete('Message-ID');
2100 $Message->make_singlepart;
2102 RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8
2110 =head2 ParseDateToISO
2112 Takes a date in an arbitrary format.
2113 Returns an ISO date and time in GMT
2117 sub ParseDateToISO {
2120 my $date_obj = RT::Date->new( $session{'CurrentUser'} );
2122 Format => 'unknown',
2125 return ( $date_obj->ISO );
2130 sub ProcessACLChanges {
2131 my $ARGSref = shift;
2133 #XXX: why don't we get ARGSref like in other Process* subs?
2137 foreach my $arg ( keys %$ARGSref ) {
2138 next unless ( $arg =~ /^(GrantRight|RevokeRight)-(\d+)-(.+?)-(\d+)$/ );
2140 my ( $method, $principal_id, $object_type, $object_id ) = ( $1, $2, $3, $4 );
2143 if ( UNIVERSAL::isa( $ARGSref->{$arg}, 'ARRAY' ) ) {
2144 @rights = @{ $ARGSref->{$arg} };
2146 @rights = $ARGSref->{$arg};
2148 @rights = grep $_, @rights;
2149 next unless @rights;
2151 my $principal = RT::Principal->new( $session{'CurrentUser'} );
2152 $principal->Load($principal_id);
2155 if ( $object_type eq 'RT::System' ) {
2157 } elsif ( $RT::ACE::OBJECT_TYPES{$object_type} ) {
2158 $obj = $object_type->new( $session{'CurrentUser'} );
2159 $obj->Load($object_id);
2160 unless ( $obj->id ) {
2161 $RT::Logger->error("couldn't load $object_type #$object_id");
2165 $RT::Logger->error("object type '$object_type' is incorrect");
2166 push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) );
2170 foreach my $right (@rights) {
2171 my ( $val, $msg ) = $principal->$method( Object => $obj, Right => $right );
2172 push( @results, $msg );
2182 ProcessACLs expects values from a series of checkboxes that describe the full
2183 set of rights a principal should have on an object.
2185 It expects form inputs with names like SetRights-PrincipalId-ObjType-ObjId
2186 instead of with the prefixes Grant/RevokeRight. Each input should be an array
2187 listing the rights the principal should have, and ProcessACLs will modify the
2188 current rights to match. Additionally, the previously unused CheckACL input
2189 listing PrincipalId-ObjType-ObjId is now used to catch cases when all the
2190 rights are removed from a principal and as such no SetRights input is
2196 my $ARGSref = shift;
2197 my (%state, @results);
2199 my $CheckACL = $ARGSref->{'CheckACL'};
2200 my @check = grep { defined } (ref $CheckACL eq 'ARRAY' ? @$CheckACL : $CheckACL);
2202 # Check if we want to grant rights to a previously rights-less user
2203 for my $type (qw(user group)) {
2204 my $key = "AddPrincipalForRights-$type";
2206 next unless $ARGSref->{$key};
2209 if ( $type eq 'user' ) {
2210 $principal = RT::User->new( $session{'CurrentUser'} );
2211 $principal->LoadByCol( Name => $ARGSref->{$key} );
2214 $principal = RT::Group->new( $session{'CurrentUser'} );
2215 $principal->LoadUserDefinedGroup( $ARGSref->{$key} );
2218 unless ($principal->PrincipalId) {
2219 push @results, loc("Couldn't load the specified principal");
2223 my $principal_id = $principal->PrincipalId;
2225 # Turn our addprincipal rights spec into a real one
2226 for my $arg (keys %$ARGSref) {
2227 next unless $arg =~ /^SetRights-addprincipal-(.+?-\d+)$/;
2229 my $tuple = "$principal_id-$1";
2230 my $key = "SetRights-$tuple";
2232 # If we have it already, that's odd, but merge them
2233 if (grep { $_ eq $tuple } @check) {
2234 $ARGSref->{$key} = [
2235 (ref $ARGSref->{$key} eq 'ARRAY' ? @{$ARGSref->{$key}} : $ARGSref->{$key}),
2236 (ref $ARGSref->{$arg} eq 'ARRAY' ? @{$ARGSref->{$arg}} : $ARGSref->{$arg}),
2239 $ARGSref->{$key} = $ARGSref->{$arg};
2240 push @check, $tuple;
2245 # Build our rights state for each Principal-Object tuple
2246 foreach my $arg ( keys %$ARGSref ) {
2247 next unless $arg =~ /^SetRights-(\d+-.+?-\d+)$/;
2250 my $value = $ARGSref->{$arg};
2251 my @rights = grep { $_ } (ref $value eq 'ARRAY' ? @$value : $value);
2252 next unless @rights;
2254 $state{$tuple} = { map { $_ => 1 } @rights };
2257 foreach my $tuple (List::MoreUtils::uniq @check) {
2258 next unless $tuple =~ /^(\d+)-(.+?)-(\d+)$/;
2260 my ( $principal_id, $object_type, $object_id ) = ( $1, $2, $3 );
2262 my $principal = RT::Principal->new( $session{'CurrentUser'} );
2263 $principal->Load($principal_id);
2266 if ( $object_type eq 'RT::System' ) {
2268 } elsif ( $RT::ACE::OBJECT_TYPES{$object_type} ) {
2269 $obj = $object_type->new( $session{'CurrentUser'} );
2270 $obj->Load($object_id);
2271 unless ( $obj->id ) {
2272 $RT::Logger->error("couldn't load $object_type #$object_id");
2276 $RT::Logger->error("object type '$object_type' is incorrect");
2277 push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) );
2281 my $acls = RT::ACL->new($session{'CurrentUser'});
2282 $acls->LimitToObject( $obj );
2283 $acls->LimitToPrincipal( Id => $principal_id );
2285 while ( my $ace = $acls->Next ) {
2286 my $right = $ace->RightName;
2288 # Has right and should have right
2289 next if delete $state{$tuple}->{$right};
2291 # Has right and shouldn't have right
2292 my ($val, $msg) = $principal->RevokeRight( Object => $obj, Right => $right );
2293 push @results, $msg;
2296 # For everything left, they don't have the right but they should
2297 for my $right (keys %{ $state{$tuple} || {} }) {
2298 delete $state{$tuple}->{$right};
2299 my ($val, $msg) = $principal->GrantRight( Object => $obj, Right => $right );
2300 push @results, $msg;
2303 # Check our state for leftovers
2304 if ( keys %{ $state{$tuple} || {} } ) {
2305 my $missed = join '|', %{$state{$tuple} || {}};
2307 "Uh-oh, it looks like we somehow missed a right in "
2308 ."ProcessACLs. Here's what was leftover: $missed"
2319 =head2 UpdateRecordObj ( ARGSRef => \%ARGS, Object => RT::Record, AttributesRef => \@attribs)
2321 @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.
2323 Returns an array of success/failure messages
2327 sub UpdateRecordObject {
2330 AttributesRef => undef,
2332 AttributePrefix => undef,
2336 my $Object = $args{'Object'};
2337 my @results = $Object->Update(
2338 AttributesRef => $args{'AttributesRef'},
2339 ARGSRef => $args{'ARGSRef'},
2340 AttributePrefix => $args{'AttributePrefix'},
2348 sub ProcessCustomFieldUpdates {
2350 CustomFieldObj => undef,
2355 my $Object = $args{'CustomFieldObj'};
2356 my $ARGSRef = $args{'ARGSRef'};
2358 my @attribs = qw(Name Type Description Queue SortOrder);
2359 my @results = UpdateRecordObject(
2360 AttributesRef => \@attribs,
2365 my $prefix = "CustomField-" . $Object->Id;
2366 if ( $ARGSRef->{"$prefix-AddValue-Name"} ) {
2367 my ( $addval, $addmsg ) = $Object->AddValue(
2368 Name => $ARGSRef->{"$prefix-AddValue-Name"},
2369 Description => $ARGSRef->{"$prefix-AddValue-Description"},
2370 SortOrder => $ARGSRef->{"$prefix-AddValue-SortOrder"},
2372 push( @results, $addmsg );
2376 = ( ref $ARGSRef->{"$prefix-DeleteValue"} eq 'ARRAY' )
2377 ? @{ $ARGSRef->{"$prefix-DeleteValue"} }
2378 : ( $ARGSRef->{"$prefix-DeleteValue"} );
2380 foreach my $id (@delete_values) {
2381 next unless defined $id;
2382 my ( $err, $msg ) = $Object->DeleteValue($id);
2383 push( @results, $msg );
2386 my $vals = $Object->Values();
2387 while ( my $cfv = $vals->Next() ) {
2388 if ( my $so = $ARGSRef->{ "$prefix-SortOrder" . $cfv->Id } ) {
2389 if ( $cfv->SortOrder != $so ) {
2390 my ( $err, $msg ) = $cfv->SetSortOrder($so);
2391 push( @results, $msg );
2401 =head2 ProcessTicketBasics ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2403 Returns an array of results messages.
2407 sub ProcessTicketBasics {
2415 my $TicketObj = $args{'TicketObj'};
2416 my $ARGSRef = $args{'ARGSRef'};
2418 my $OrigOwner = $TicketObj->Owner;
2433 # Canonicalize Queue and Owner to their IDs if they aren't numeric
2434 for my $field (qw(Queue Owner)) {
2435 if ( $ARGSRef->{$field} and ( $ARGSRef->{$field} !~ /^(\d+)$/ ) ) {
2436 my $class = $field eq 'Owner' ? "RT::User" : "RT::$field";
2437 my $temp = $class->new(RT->SystemUser);
2438 $temp->Load( $ARGSRef->{$field} );
2440 $ARGSRef->{$field} = $temp->id;
2445 # Status isn't a field that can be set to a null value.
2446 # RT core complains if you try
2447 delete $ARGSRef->{'Status'} unless $ARGSRef->{'Status'};
2449 my @results = UpdateRecordObject(
2450 AttributesRef => \@attribs,
2451 Object => $TicketObj,
2452 ARGSRef => $ARGSRef,
2455 # We special case owner changing, so we can use ForceOwnerChange
2456 if ( $ARGSRef->{'Owner'}
2457 && $ARGSRef->{'Owner'} !~ /\D/
2458 && ( $OrigOwner != $ARGSRef->{'Owner'} ) ) {
2460 if ( $ARGSRef->{'ForceOwnerChange'} ) {
2461 $ChownType = "Force";
2467 my ( $val, $msg ) = $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType );
2468 push( @results, $msg );
2476 sub ProcessTicketReminders {
2483 my $Ticket = $args{'TicketObj'};
2484 my $args = $args{'ARGSRef'};
2487 my $reminder_collection = $Ticket->Reminders->Collection;
2489 if ( $args->{'update-reminders'} ) {
2490 while ( my $reminder = $reminder_collection->Next ) {
2491 my $resolve_status = $reminder->QueueObj->Lifecycle->ReminderStatusOnResolve;
2492 if ( $reminder->Status ne $resolve_status && $args->{ 'Complete-Reminder-' . $reminder->id } ) {
2493 $Ticket->Reminders->Resolve($reminder);
2495 elsif ( $reminder->Status eq $resolve_status && !$args->{ 'Complete-Reminder-' . $reminder->id } ) {
2496 $Ticket->Reminders->Open($reminder);
2499 if ( exists( $args->{ 'Reminder-Subject-' . $reminder->id } ) && ( $reminder->Subject ne $args->{ 'Reminder-Subject-' . $reminder->id } )) {
2500 $reminder->SetSubject( $args->{ 'Reminder-Subject-' . $reminder->id } ) ;
2503 if ( exists( $args->{ 'Reminder-Owner-' . $reminder->id } ) && ( $reminder->Owner != $args->{ 'Reminder-Owner-' . $reminder->id } )) {
2504 $reminder->SetOwner( $args->{ 'Reminder-Owner-' . $reminder->id } , "Force" ) ;
2507 if ( exists( $args->{ 'Reminder-Due-' . $reminder->id } ) && $args->{ 'Reminder-Due-' . $reminder->id } ne '' ) {
2508 my $DateObj = RT::Date->new( $session{'CurrentUser'} );
2510 Format => 'unknown',
2511 Value => $args->{ 'Reminder-Due-' . $reminder->id }
2513 if ( defined $DateObj->Unix && $DateObj->Unix != $reminder->DueObj->Unix ) {
2514 $reminder->SetDue( $DateObj->ISO );
2520 if ( $args->{'NewReminder-Subject'} ) {
2521 my $due_obj = RT::Date->new( $session{'CurrentUser'} );
2523 Format => 'unknown',
2524 Value => $args->{'NewReminder-Due'}
2526 my ( $add_id, $msg, $txnid ) = $Ticket->Reminders->Add(
2527 Subject => $args->{'NewReminder-Subject'},
2528 Owner => $args->{'NewReminder-Owner'},
2529 Due => $due_obj->ISO
2531 push @results, loc("Reminder '[_1]' added", $args->{'NewReminder-Subject'});
2536 sub ProcessTicketCustomFieldUpdates {
2538 $args{'Object'} = delete $args{'TicketObj'};
2539 my $ARGSRef = { %{ $args{'ARGSRef'} } };
2541 # Build up a list of objects that we want to work with
2542 my %custom_fields_to_mod;
2543 foreach my $arg ( keys %$ARGSRef ) {
2544 if ( $arg =~ /^Ticket-(\d+-.*)/ ) {
2545 $ARGSRef->{"Object-RT::Ticket-$1"} = delete $ARGSRef->{$arg};
2546 } elsif ( $arg =~ /^CustomField-(\d+-.*)/ ) {
2547 $ARGSRef->{"Object-RT::Ticket--$1"} = delete $ARGSRef->{$arg};
2551 return ProcessObjectCustomFieldUpdates( %args, ARGSRef => $ARGSRef );
2554 sub ProcessObjectCustomFieldUpdates {
2556 my $ARGSRef = $args{'ARGSRef'};
2559 # Build up a list of objects that we want to work with
2560 my %custom_fields_to_mod;
2561 foreach my $arg ( keys %$ARGSRef ) {
2563 # format: Object-<object class>-<object id>-CustomField-<CF id>-<commands>
2564 next unless $arg =~ /^Object-([\w:]+)-(\d*)-CustomField-(\d+)-(.*)$/;
2566 # For each of those objects, find out what custom fields we want to work with.
2567 $custom_fields_to_mod{$1}{ $2 || 0 }{$3}{$4} = $ARGSRef->{$arg};
2570 # For each of those objects
2571 foreach my $class ( keys %custom_fields_to_mod ) {
2572 foreach my $id ( keys %{ $custom_fields_to_mod{$class} } ) {
2573 my $Object = $args{'Object'};
2574 $Object = $class->new( $session{'CurrentUser'} )
2575 unless $Object && ref $Object eq $class;
2577 $Object->Load($id) unless ( $Object->id || 0 ) == $id;
2578 unless ( $Object->id ) {
2579 $RT::Logger->warning("Couldn't load object $class #$id");
2583 foreach my $cf ( keys %{ $custom_fields_to_mod{$class}{$id} } ) {
2584 my $CustomFieldObj = RT::CustomField->new( $session{'CurrentUser'} );
2585 $CustomFieldObj->SetContextObject($Object);
2586 $CustomFieldObj->LoadById($cf);
2587 unless ( $CustomFieldObj->id ) {
2588 $RT::Logger->warning("Couldn't load custom field #$cf");
2592 _ProcessObjectCustomFieldUpdates(
2593 Prefix => "Object-$class-$id-CustomField-$cf-",
2595 CustomField => $CustomFieldObj,
2596 ARGS => $custom_fields_to_mod{$class}{$id}{$cf},
2604 sub _ProcessObjectCustomFieldUpdates {
2606 my $cf = $args{'CustomField'};
2607 my $cf_type = $cf->Type || '';
2609 # Remove blank Values since the magic field will take care of this. Sometimes
2610 # the browser gives you a blank value which causes CFs to be processed twice
2611 if ( defined $args{'ARGS'}->{'Values'}
2612 && !length $args{'ARGS'}->{'Values'}
2613 && $args{'ARGS'}->{'Values-Magic'} )
2615 delete $args{'ARGS'}->{'Values'};
2619 foreach my $arg ( keys %{ $args{'ARGS'} } ) {
2621 # skip category argument
2622 next if $arg eq 'Category';
2624 # since http won't pass in a form element with a null value, we need
2626 if ( $arg eq 'Values-Magic' ) {
2628 # We don't care about the magic, if there's really a values element;
2629 next if defined $args{'ARGS'}->{'Value'} && length $args{'ARGS'}->{'Value'};
2630 next if defined $args{'ARGS'}->{'Values'} && length $args{'ARGS'}->{'Values'};
2632 # "Empty" values does not mean anything for Image and Binary fields
2633 next if $cf_type =~ /^(?:Image|Binary)$/;
2636 $args{'ARGS'}->{'Values'} = undef;
2640 if ( ref $args{'ARGS'}->{$arg} eq 'ARRAY' ) {
2641 @values = @{ $args{'ARGS'}->{$arg} };
2642 } elsif ( $cf_type =~ /text/i ) { # Both Text and Wikitext
2643 @values = ( $args{'ARGS'}->{$arg} );
2645 @values = split /\r*\n/, $args{'ARGS'}->{$arg}
2646 if defined $args{'ARGS'}->{$arg};
2648 @values = grep length, map {
2654 grep defined, @values;
2656 if ( $arg eq 'AddValue' || $arg eq 'Value' ) {
2657 foreach my $value (@values) {
2658 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
2662 push( @results, $msg );
2664 } elsif ( $arg eq 'Upload' ) {
2665 my $value_hash = _UploadedFile( $args{'Prefix'} . $arg ) or next;
2666 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( %$value_hash, Field => $cf, );
2667 push( @results, $msg );
2668 } elsif ( $arg eq 'DeleteValues' ) {
2669 foreach my $value (@values) {
2670 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
2674 push( @results, $msg );
2676 } elsif ( $arg eq 'DeleteValueIds' ) {
2677 foreach my $value (@values) {
2678 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
2682 push( @results, $msg );
2684 } elsif ( $arg eq 'Values' && !$cf->Repeated ) {
2685 my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id );
2688 foreach my $value (@values) {
2689 if ( my $entry = $cf_values->HasEntry($value) ) {
2690 $values_hash{ $entry->id } = 1;
2694 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
2698 push( @results, $msg );
2699 $values_hash{$val} = 1 if $val;
2702 # For Date Cfs, @values is empty when there is no changes (no datas in form input)
2703 return @results if ( $cf->Type =~ /^Date(?:Time)?$/ && ! @values );
2705 $cf_values->RedoSearch;
2706 while ( my $cf_value = $cf_values->Next ) {
2707 next if $values_hash{ $cf_value->id };
2709 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
2711 ValueId => $cf_value->id
2713 push( @results, $msg );
2715 } elsif ( $arg eq 'Values' ) {
2716 my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id );
2718 # keep everything up to the point of difference, delete the rest
2720 foreach my $old_cf ( @{ $cf_values->ItemsArrayRef } ) {
2721 if ( !$delete_flag and @values and $old_cf->Content eq $values[0] ) {
2730 # now add/replace extra things, if any
2731 foreach my $value (@values) {
2732 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
2736 push( @results, $msg );
2741 loc("User asked for an unknown update type for custom field [_1] for [_2] object #[_3]",
2742 $cf->Name, ref $args{'Object'},
2752 =head2 ProcessTicketWatchers ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2754 Returns an array of results messages.
2758 sub ProcessTicketWatchers {
2766 my $Ticket = $args{'TicketObj'};
2767 my $ARGSRef = $args{'ARGSRef'};
2771 foreach my $key ( keys %$ARGSRef ) {
2773 # Delete deletable watchers
2774 if ( $key =~ /^Ticket-DeleteWatcher-Type-(.*)-Principal-(\d+)$/ ) {
2775 my ( $code, $msg ) = $Ticket->DeleteWatcher(
2779 push @results, $msg;
2782 # Delete watchers in the simple style demanded by the bulk manipulator
2783 elsif ( $key =~ /^Delete(Requestor|Cc|AdminCc)$/ ) {
2784 my ( $code, $msg ) = $Ticket->DeleteWatcher(
2785 Email => $ARGSRef->{$key},
2788 push @results, $msg;
2791 # Add new wathchers by email address
2792 elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor)$/
2793 and $key =~ /^WatcherTypeEmail(\d*)$/ )
2796 #They're in this order because otherwise $1 gets clobbered :/
2797 my ( $code, $msg ) = $Ticket->AddWatcher(
2798 Type => $ARGSRef->{$key},
2799 Email => $ARGSRef->{ "WatcherAddressEmail" . $1 }
2801 push @results, $msg;
2804 #Add requestors in the simple style demanded by the bulk manipulator
2805 elsif ( $key =~ /^Add(Requestor|Cc|AdminCc)$/ ) {
2806 my ( $code, $msg ) = $Ticket->AddWatcher(
2808 Email => $ARGSRef->{$key}
2810 push @results, $msg;
2813 # Add new watchers by owner
2814 elsif ( $key =~ /^Ticket-AddWatcher-Principal-(\d*)$/ ) {
2815 my $principal_id = $1;
2816 my $form = $ARGSRef->{$key};
2817 foreach my $value ( ref($form) ? @{$form} : ($form) ) {
2818 next unless $value =~ /^(?:AdminCc|Cc|Requestor)$/i;
2820 my ( $code, $msg ) = $Ticket->AddWatcher(
2822 PrincipalId => $principal_id
2824 push @results, $msg;
2834 =head2 ProcessTicketDates ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2836 Returns an array of results messages.
2840 sub ProcessTicketDates {
2847 my $Ticket = $args{'TicketObj'};
2848 my $ARGSRef = $args{'ARGSRef'};
2853 my @date_fields = qw(
2861 #Run through each field in this list. update the value if apropriate
2862 foreach my $field (@date_fields) {
2863 next unless exists $ARGSRef->{ $field . '_Date' };
2864 next if $ARGSRef->{ $field . '_Date' } eq '';
2868 my $DateObj = RT::Date->new( $session{'CurrentUser'} );
2870 Format => 'unknown',
2871 Value => $ARGSRef->{ $field . '_Date' }
2874 my $obj = $field . "Obj";
2875 if ( ( defined $DateObj->Unix )
2876 and ( $DateObj->Unix != $Ticket->$obj()->Unix() ) )
2878 my $method = "Set$field";
2879 my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO );
2880 push @results, "$msg";
2890 =head2 ProcessTicketLinks ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2892 Returns an array of results messages.
2896 sub ProcessTicketLinks {
2903 my $Ticket = $args{'TicketObj'};
2904 my $ARGSRef = $args{'ARGSRef'};
2906 my (@results) = ProcessRecordLinks( RecordObj => $Ticket, ARGSRef => $ARGSRef );
2908 #Merge if we need to
2909 if ( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ) {
2910 $ARGSRef->{ $Ticket->Id . "-MergeInto" } =~ s/\s+//g;
2911 my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $Ticket->Id . "-MergeInto" } );
2912 push @results, $msg;
2919 sub ProcessRecordLinks {
2926 my $Record = $args{'RecordObj'};
2927 my $ARGSRef = $args{'ARGSRef'};
2931 # Delete links that are gone gone gone.
2932 foreach my $arg ( keys %$ARGSRef ) {
2933 if ( $arg =~ /DeleteLink-(.*?)-(DependsOn|MemberOf|RefersTo)-(.*)$/ ) {
2938 my ( $val, $msg ) = $Record->DeleteLink(
2944 push @results, $msg;
2950 my @linktypes = qw( DependsOn MemberOf RefersTo );
2952 foreach my $linktype (@linktypes) {
2953 if ( $ARGSRef->{ $Record->Id . "-$linktype" } ) {
2954 $ARGSRef->{ $Record->Id . "-$linktype" } = join( ' ', @{ $ARGSRef->{ $Record->Id . "-$linktype" } } )
2955 if ref( $ARGSRef->{ $Record->Id . "-$linktype" } );
2957 for my $luri ( split( / /, $ARGSRef->{ $Record->Id . "-$linktype" } ) ) {
2959 $luri =~ s/\s+$//; # Strip trailing whitespace
2960 my ( $val, $msg ) = $Record->AddLink(
2964 push @results, $msg;
2967 if ( $ARGSRef->{ "$linktype-" . $Record->Id } ) {
2968 $ARGSRef->{ "$linktype-" . $Record->Id } = join( ' ', @{ $ARGSRef->{ "$linktype-" . $Record->Id } } )
2969 if ref( $ARGSRef->{ "$linktype-" . $Record->Id } );
2971 for my $luri ( split( / /, $ARGSRef->{ "$linktype-" . $Record->Id } ) ) {
2973 my ( $val, $msg ) = $Record->AddLink(
2978 push @results, $msg;
2986 =head2 _UploadedFile ( $arg );
2988 Takes a CGI parameter name; if a file is uploaded under that name,
2989 return a hash reference suitable for AddCustomFieldValue's use:
2990 C<( Value => $filename, LargeContent => $content, ContentType => $type )>.
2992 Returns C<undef> if no files were uploaded in the C<$arg> field.
2998 my $cgi_object = $m->cgi_object;
2999 my $fh = $cgi_object->upload($arg) or return undef;
3000 my $upload_info = $cgi_object->uploadInfo($fh);
3002 my $filename = "$fh";
3003 $filename =~ s#^.*[\\/]##;
3008 LargeContent => do { local $/; scalar <$fh> },
3009 ContentType => $upload_info->{'Content-Type'},
3013 sub GetColumnMapEntry {
3014 my %args = ( Map => {}, Name => '', Attribute => undef, @_ );
3016 # deal with the simplest thing first
3017 if ( $args{'Map'}{ $args{'Name'} } ) {
3018 return $args{'Map'}{ $args{'Name'} }{ $args{'Attribute'} };
3022 elsif ( my ( $mainkey, $subkey ) = $args{'Name'} =~ /^(.*?)\.{(.+)}$/ ) {
3023 return undef unless $args{'Map'}->{$mainkey};
3024 return $args{'Map'}{$mainkey}{ $args{'Attribute'} }
3025 unless ref $args{'Map'}{$mainkey}{ $args{'Attribute'} } eq 'CODE';
3027 return sub { $args{'Map'}{$mainkey}{ $args{'Attribute'} }->( @_, $subkey ) };
3032 sub ProcessColumnMapValue {
3034 my %args = ( Arguments => [], Escape => 1, @_ );
3037 if ( UNIVERSAL::isa( $value, 'CODE' ) ) {
3038 my @tmp = $value->( @{ $args{'Arguments'} } );
3039 return ProcessColumnMapValue( ( @tmp > 1 ? \@tmp : $tmp[0] ), %args );
3040 } elsif ( UNIVERSAL::isa( $value, 'ARRAY' ) ) {
3041 return join '', map ProcessColumnMapValue( $_, %args ), @$value;
3042 } elsif ( UNIVERSAL::isa( $value, 'SCALAR' ) ) {
3047 return $m->interp->apply_escapes( $value, 'h' ) if $args{'Escape'};
3051 =head2 GetPrincipalsMap OBJECT, CATEGORIES
3053 Returns an array suitable for passing to /Admin/Elements/EditRights with the
3054 principal collections mapped from the categories given.
3058 sub GetPrincipalsMap {
3063 my $system = RT::Groups->new($session{'CurrentUser'});
3064 $system->LimitToSystemInternalGroups();
3065 $system->OrderBy( FIELD => 'Type', ORDER => 'ASC' );
3067 'System' => $system, # loc_left_pair
3072 my $groups = RT::Groups->new($session{'CurrentUser'});
3073 $groups->LimitToUserDefinedGroups();
3074 $groups->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3076 # Only show groups who have rights granted on this object
3077 $groups->WithGroupRight(
3080 IncludeSystemRights => 0,
3081 IncludeSubgroupMembers => 0,
3085 'User Groups' => $groups, # loc_left_pair
3090 my $roles = RT::Groups->new($session{'CurrentUser'});
3092 if ($object->isa('RT::System')) {
3093 $roles->LimitToRolesForSystem();
3095 elsif ($object->isa('RT::Queue')) {
3096 $roles->LimitToRolesForQueue($object->Id);
3099 $RT::Logger->warn("Skipping unknown object type ($object) for Role principals");
3102 $roles->OrderBy( FIELD => 'Type', ORDER => 'ASC' );
3104 'Roles' => $roles, # loc_left_pair
3109 my $Users = RT->PrivilegedUsers->UserMembersObj();
3110 $Users->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
3112 # Only show users who have rights granted on this object
3113 my $group_members = $Users->WhoHaveGroupRight(
3116 IncludeSystemRights => 0,
3117 IncludeSubgroupMembers => 0,
3120 # Limit to UserEquiv groups
3121 my $groups = $Users->NewAlias('Groups');
3125 ALIAS2 => $group_members,
3128 $Users->Limit( ALIAS => $groups, FIELD => 'Domain', VALUE => 'ACLEquivalence' );
3129 $Users->Limit( ALIAS => $groups, FIELD => 'Type', VALUE => 'UserEquiv' );
3133 $m->scomp('/Elements/ShowUser', User => $_[0], NoEscape => 1)
3136 'Users' => $Users, # loc_left_pair
3144 =head2 _load_container_object ( $type, $id );
3146 Instantiate container object for saving searches.
3150 sub _load_container_object {
3151 my ( $obj_type, $obj_id ) = @_;
3152 return RT::SavedSearch->new( $session{'CurrentUser'} )->_load_privacy_object( $obj_type, $obj_id );
3155 =head2 _parse_saved_search ( $arg );
3157 Given a serialization string for saved search, and returns the
3158 container object and the search id.
3162 sub _parse_saved_search {
3164 return unless $spec;
3165 if ( $spec !~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) {
3172 return ( _load_container_object( $obj_type, $obj_id ), $search_id );
3175 =head2 ScrubHTML content
3177 Removes unsafe and undesired HTML from the passed content
3183 my $Content = shift;
3184 $SCRUBBER = _NewScrubber() unless $SCRUBBER;
3186 $Content = '' if !defined($Content);
3187 return $SCRUBBER->scrub($Content);
3192 Returns a new L<HTML::Scrubber> object.
3194 If you need to be more lax about what HTML tags and attributes are allowed,
3195 create C</opt/rt4/local/lib/RT/Interface/Web_Local.pm> with something like the
3198 package HTML::Mason::Commands;
3199 # Let tables through
3200 push @SCRUBBER_ALLOWED_TAGS, qw(TABLE THEAD TBODY TFOOT TR TD TH);
3205 our @SCRUBBER_ALLOWED_TAGS = qw(
3206 A B U P BR I HR BR SMALL EM FONT SPAN STRONG SUB SUP STRIKE H1 H2 H3 H4 H5
3207 H6 DIV UL OL LI DL DT DD PRE BLOCKQUOTE BDO
3210 our %SCRUBBER_ALLOWED_ATTRIBUTES = (
3211 # Match http, ftp and relative urls
3212 # XXX: we also scrub format strings with this module then allow simple config options
3213 href => qr{^(?:http:|ftp:|https:|/|__Web(?:Path|BaseURL|URL)__)}i,
3219 (?:(?:background-)?color: \s*
3220 (?:rgb\(\s* \d+, \s* \d+, \s* \d+ \s*\) | # rgb(d,d,d)
3221 \#[a-f0-9]{3,6} | # #fff or #ffffff
3222 [\w\-]+ # green, light-blue, etc.
3224 text-align: \s* \w+ |
3225 font-size: \s* [\w.\-]+ |
3226 font-family: \s* [\w\s"',.\-]+ |
3227 font-weight: \s* [\w\-]+ |
3229 # MS Office styles, which are probably fine. If we don't, then any
3230 # associated styles in the same attribute get stripped.
3231 mso-[\w\-]+?: \s* [\w\s"',.\-]+
3233 +$ # one or more of these allowed properties from here 'till sunset
3235 dir => qr/^(rtl|ltr)$/i,
3236 lang => qr/^\w+(-\w+)?$/,
3239 our %SCRUBBER_RULES = ();
3242 require HTML::Scrubber;
3243 my $scrubber = HTML::Scrubber->new();
3247 %SCRUBBER_ALLOWED_ATTRIBUTES,
3248 '*' => 0, # require attributes be explicitly allowed
3251 $scrubber->deny(qw[*]);
3252 $scrubber->allow(@SCRUBBER_ALLOWED_TAGS);
3253 $scrubber->rules(%SCRUBBER_RULES);
3255 # Scrubbing comments is vital since IE conditional comments can contain
3256 # arbitrary HTML and we'd pass it right on through.
3257 $scrubber->comment(0);
3264 Redispatches to L<RT::Interface::Web/EncodeJSON>
3269 RT::Interface::Web::EncodeJSON(@_);
3272 package RT::Interface::Web;
3273 RT::Base->_ImportOverlays();