1 # BEGIN BPS TAGGED BLOCK {{{
5 # This software is Copyright (c) 1996-2011 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::Session;
74 =head2 EscapeUTF8 SCALARREF
76 does a css-busting but minimalist escaping of whatever html you're passing in.
82 return unless defined $$ref;
87 $$ref =~ s/\(/(/g;
88 $$ref =~ s/\)/)/g;
97 =head2 EscapeURI SCALARREF
99 Escapes URI component according to RFC2396
105 return unless defined $$ref;
108 $$ref =~ s/([^a-zA-Z0-9_.!~*'()-])/uc sprintf("%%%02X", ord($1))/eg;
113 # {{{ WebCanonicalizeInfo
115 =head2 WebCanonicalizeInfo();
117 Different web servers set different environmental varibles. This
118 function must return something suitable for REMOTE_USER. By default,
119 just downcase $ENV{'REMOTE_USER'}
123 sub WebCanonicalizeInfo {
124 return $ENV{'REMOTE_USER'} ? lc $ENV{'REMOTE_USER'} : $ENV{'REMOTE_USER'};
129 # {{{ WebExternalAutoInfo
131 =head2 WebExternalAutoInfo($user);
133 Returns a hash of user attributes, used when WebExternalAuto is set.
137 sub WebExternalAutoInfo {
142 # default to making Privileged users, even if they specify
143 # some other default Attributes
144 if ( !$RT::AutoCreate
145 || ( ref($RT::AutoCreate) && not exists $RT::AutoCreate->{Privileged} ) )
147 $user_info{'Privileged'} = 1;
150 if ( $^O !~ /^(?:riscos|MacOS|MSWin32|dos|os2)$/ ) {
152 # Populate fields with information from Unix /etc/passwd
154 my ( $comments, $realname ) = ( getpwnam($user) )[ 5, 6 ];
155 $user_info{'Comments'} = $comments if defined $comments;
156 $user_info{'RealName'} = $realname if defined $realname;
157 } elsif ( $^O eq 'MSWin32' and eval 'use Net::AdminMisc; 1' ) {
159 # Populate fields with information from NT domain controller
162 # and return the wad of stuff
171 $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8");
173 $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ];
175 # Roll back any dangling transactions from a previous failed connection
176 $RT::Handle->ForceRollback() if $RT::Handle->TransactionDepth;
178 MaybeEnableSQLStatementLog();
180 # avoid reentrancy, as suggested by masonbook
181 local *HTML::Mason::Commands::session unless $HTML::Mason::Commands::m->is_subrequest;
183 $HTML::Mason::Commands::m->autoflush( $HTML::Mason::Commands::m->request_comp->attr('AutoFlush') )
184 if ( $HTML::Mason::Commands::m->request_comp->attr_exists('AutoFlush') );
187 PreprocessTimeUpdates($ARGS);
189 MaybeShowInstallModePage();
191 $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS );
193 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new() unless _UserLoggedIn();
195 # Process session-related callbacks before any auth attempts
196 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Session', CallbackPage => '/autohandler' );
198 MaybeRejectPrivateComponentRequest();
200 MaybeShowNoAuthPage($ARGS);
202 AttemptExternalAuth($ARGS) if RT->Config->Get('WebExternalAuthContinuous') or not _UserLoggedIn();
204 _ForceLogout() unless _UserLoggedIn();
206 # Process per-page authentication callbacks
207 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Auth', CallbackPage => '/autohandler' );
209 unless ( _UserLoggedIn() ) {
212 # Authenticate if the user is trying to login via user/pass query args
213 my ($authed, $msg) = AttemptPasswordAuthentication($ARGS);
216 my $m = $HTML::Mason::Commands::m;
218 # REST urls get a special 401 response
219 if ($m->request_comp->path =~ '^/REST/\d+\.\d+/') {
220 $HTML::Mason::Commands::r->content_type("text/plain");
221 $m->error_format("text");
222 $m->out("RT/$RT::VERSION 401 Credentials required\n");
223 $m->out("\n$msg\n") if $msg;
226 # Specially handle /index.html so that we get a nicer URL
227 elsif ( $m->request_comp->path eq '/index.html' ) {
228 my $next = SetNextPage(RT->Config->Get('WebURL'));
229 $m->comp('/NoAuth/Login.html', next => $next, actions => [$msg]);
233 TangentForLogin(results => ($msg ? LoginError($msg) : undef));
238 # now it applies not only to home page, but any dashboard that can be used as a workspace
239 $HTML::Mason::Commands::session{'home_refresh_interval'} = $ARGS->{'HomeRefreshInterval'}
240 if ( $ARGS->{'HomeRefreshInterval'} );
242 # Process per-page global callbacks
243 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Default', CallbackPage => '/autohandler' );
245 ShowRequestedPage($ARGS);
246 LogRecordedSQLStatements();
248 # Process per-page final cleanup callbacks
249 $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Final', CallbackPage => '/autohandler' );
254 delete $HTML::Mason::Commands::session{'CurrentUser'};
258 if ( $HTML::Mason::Commands::session{CurrentUser} && $HTML::Mason::Commands::session{'CurrentUser'}->id ) {
266 =head2 LoginError ERROR
268 Pushes a login error into the Actions session store and returns the hash key.
274 my $key = Digest::MD5::md5_hex( rand(1024) );
275 push @{ $HTML::Mason::Commands::session{"Actions"}->{$key} ||= [] }, $new;
276 $HTML::Mason::Commands::session{'i'}++;
280 =head2 SetNextPage [PATH]
282 Intuits and stashes the next page in the sesssion hash. If PATH is
283 specified, uses that instead of the value of L<IntuitNextPage()>. Returns
289 my $next = shift || IntuitNextPage();
290 my $hash = Digest::MD5::md5_hex($next . $$ . rand(1024));
292 $HTML::Mason::Commands::session{'NextPage'}->{$hash} = $next;
293 $HTML::Mason::Commands::session{'i'}++;
300 =head2 TangentForLogin [HASH]
302 Redirects to C</NoAuth/Login.html>, setting the value of L<IntuitNextPage> as
303 the next page. Optionally takes a hash which is dumped into query params.
307 sub TangentForLogin {
308 my $hash = SetNextPage();
309 my %query = (@_, next => $hash);
310 my $login = RT->Config->Get('WebURL') . 'NoAuth/Login.html?';
311 $login .= $HTML::Mason::Commands::m->comp('/Elements/QueryString', %query);
315 =head2 TangentForLoginWithError ERROR
317 Localizes the passed error message, stashes it with L<LoginError> and then
318 calls L<TangentForLogin> with the appropriate results key.
322 sub TangentForLoginWithError {
323 my $key = LoginError(HTML::Mason::Commands::loc(@_));
324 TangentForLogin( results => $key );
327 =head2 IntuitNextPage
329 Attempt to figure out the path to which we should return the user after a
330 tangent. The current request URL is used, or failing that, the C<WebURL>
331 configuration variable.
338 # This includes any query parameters. Redirect will take care of making
339 # it an absolute URL.
340 if ($ENV{'REQUEST_URI'}) {
341 $req_uri = $ENV{'REQUEST_URI'};
343 # collapse multiple leading slashes so the first part doesn't look like
344 # a hostname of a schema-less URI
345 $req_uri =~ s{^/+}{/};
348 my $next = defined $req_uri ? $req_uri : RT->Config->Get('WebURL');
351 my $uri = URI->new($next);
353 # You get undef scheme with a relative uri like "/Search/Build.html"
354 unless (!defined($uri->scheme) || $uri->scheme eq 'http' || $uri->scheme eq 'https') {
355 $next = RT->Config->Get('WebURL');
358 # Make sure we're logging in to the same domain
359 # You can get an undef authority with a relative uri like "index.html"
360 my $uri_base_url = URI->new(RT->Config->Get('WebBaseURL'));
361 unless (!defined($uri->authority) || $uri->authority eq $uri_base_url->authority) {
362 $next = RT->Config->Get('WebURL');
368 =head2 MaybeShowInstallModePage
370 This function, called exclusively by RT's autohandler, dispatches
371 a request to RT's Installation workflow, only if Install Mode is enabled in the configuration file.
373 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
377 sub MaybeShowInstallModePage {
378 return unless RT->InstallMode;
380 my $m = $HTML::Mason::Commands::m;
381 if ( $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex') ) {
383 } elsif ( $m->request_comp->path !~ '^(/+)Install/' ) {
384 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "Install/index.html" );
391 =head2 MaybeShowNoAuthPage \%ARGS
393 This function, called exclusively by RT's autohandler, dispatches
394 a request to the page a user requested (but only if it matches the "noauth" regex.
396 If it serves a page, it stops mason processing. Otherwise, mason just keeps running through the autohandler
400 sub MaybeShowNoAuthPage {
403 my $m = $HTML::Mason::Commands::m;
405 return unless $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex');
407 # Don't show the login page to logged in users
408 Redirect(RT->Config->Get('WebURL'))
409 if $m->base_comp->path eq '/NoAuth/Login.html' and _UserLoggedIn();
411 # If it's a noauth file, don't ask for auth.
413 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
417 =head2 MaybeRejectPrivateComponentRequest
419 This function will reject calls to private components, like those under
420 C</Elements>. If the requested path is a private component then we will
421 abort with a C<403> error.
425 sub MaybeRejectPrivateComponentRequest {
426 my $m = $HTML::Mason::Commands::m;
427 my $path = $m->request_comp->path;
429 # We do not check for dhandler here, because requesting our dhandlers
430 # directly is okay. Mason will invoke the dhandler with a dhandler_arg of
436 _elements | # mobile UI
438 autohandler | # requesting this directly is suspicious
440 ( $ | / ) # trailing slash or end of path
448 =head2 ShowRequestedPage \%ARGS
450 This function, called exclusively by RT's autohandler, dispatches
451 a request to the page a user requested (making sure that unpriviled users
452 can only see self-service pages.
456 sub ShowRequestedPage {
459 my $m = $HTML::Mason::Commands::m;
463 # If the user isn't privileged, they can only see SelfService
464 unless ( $HTML::Mason::Commands::session{'CurrentUser'}->Privileged ) {
466 # if the user is trying to access a ticket, redirect them
467 if ( $m->request_comp->path =~ '^(/+)Ticket/Display.html' && $ARGS->{'id'} ) {
468 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/Display.html?id=" . $ARGS->{'id'} );
471 # otherwise, drop the user at the SelfService default page
472 elsif ( $m->base_comp->path !~ RT->Config->Get('SelfServiceRegex') ) {
473 RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/" );
476 # if user is in SelfService dir let him do anything
478 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
481 $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %$ARGS );
486 sub AttemptExternalAuth {
489 return unless ( RT->Config->Get('WebExternalAuth') );
491 my $user = $ARGS->{user};
492 my $m = $HTML::Mason::Commands::m;
494 # If RT is configured for external auth, let's go through and get REMOTE_USER
496 # do we actually have a REMOTE_USER equivlent?
497 if ( RT::Interface::Web::WebCanonicalizeInfo() ) {
498 my $orig_user = $user;
500 $user = RT::Interface::Web::WebCanonicalizeInfo();
501 my $load_method = RT->Config->Get('WebExternalGecos') ? 'LoadByGecos' : 'Load';
503 if ( $^O eq 'MSWin32' and RT->Config->Get('WebExternalGecos') ) {
504 my $NodeName = Win32::NodeName();
505 $user =~ s/^\Q$NodeName\E\\//i;
508 InstantiateNewSession() unless _UserLoggedIn;
509 $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
510 $HTML::Mason::Commands::session{'CurrentUser'}->$load_method($user);
512 if ( RT->Config->Get('WebExternalAuto') and not _UserLoggedIn() ) {
514 # Create users on-the-fly
515 my $UserObj = RT::User->new($RT::SystemUser);
516 my ( $val, $msg ) = $UserObj->Create(
517 %{ ref RT->Config->Get('AutoCreate') ? RT->Config->Get('AutoCreate') : {} },
524 # now get user specific information, to better create our user.
525 my $new_user_info = RT::Interface::Web::WebExternalAutoInfo($user);
527 # set the attributes that have been defined.
528 foreach my $attribute ( $UserObj->WritableAttributes ) {
530 Attribute => $attribute,
532 UserInfo => $new_user_info,
533 CallbackName => 'NewUser',
534 CallbackPage => '/autohandler'
536 my $method = "Set$attribute";
537 $UserObj->$method( $new_user_info->{$attribute} ) if defined $new_user_info->{$attribute};
539 $HTML::Mason::Commands::session{'CurrentUser'}->Load($user);
542 # we failed to successfully create the user. abort abort abort.
543 delete $HTML::Mason::Commands::session{'CurrentUser'};
545 if (RT->Config->Get('WebFallbackToInternalAuth')) {
546 TangentForLoginWithError('Cannot create user: [_1]', $msg);
553 if ( _UserLoggedIn() ) {
554 $m->callback( %$ARGS, CallbackName => 'ExternalAuthSuccessfulLogin', CallbackPage => '/autohandler' );
556 delete $HTML::Mason::Commands::session{'CurrentUser'};
559 if ( RT->Config->Get('WebExternalOnly') ) {
560 TangentForLoginWithError('You are not an authorized user');
563 } elsif ( RT->Config->Get('WebFallbackToInternalAuth') ) {
564 unless ( defined $HTML::Mason::Commands::session{'CurrentUser'} ) {
565 # XXX unreachable due to prior defaulting in HandleRequest (check c34d108)
566 TangentForLoginWithError('You are not an authorized user');
570 # WebExternalAuth is set, but we don't have a REMOTE_USER. abort
571 # XXX: we must return AUTH_REQUIRED status or we fallback to
572 # internal auth here too.
573 delete $HTML::Mason::Commands::session{'CurrentUser'}
574 if defined $HTML::Mason::Commands::session{'CurrentUser'};
578 sub AttemptPasswordAuthentication {
580 return unless defined $ARGS->{user} && defined $ARGS->{pass};
582 my $user_obj = RT::CurrentUser->new();
583 $user_obj->Load( $ARGS->{user} );
585 my $m = $HTML::Mason::Commands::m;
587 unless ( $user_obj->id && $user_obj->IsPassword( $ARGS->{pass} ) ) {
588 $RT::Logger->error("FAILED LOGIN for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
589 $m->callback( %$ARGS, CallbackName => 'FailedLogin', CallbackPage => '/autohandler' );
590 return (0, HTML::Mason::Commands::loc('Your username or password is incorrect'));
593 $RT::Logger->info("Successful login for @{[$ARGS->{user}]} from $ENV{'REMOTE_ADDR'}");
595 # It's important to nab the next page from the session before we blow
597 my $next = delete $HTML::Mason::Commands::session{'NextPage'}->{$ARGS->{'next'} || ''};
599 InstantiateNewSession();
600 $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
603 $m->callback( %$ARGS, CallbackName => 'SuccessfulLogin', CallbackPage => '/autohandler' );
605 # Really the only time we don't want to redirect here is if we were
606 # passed user and pass as query params in the URL.
610 elsif ($ARGS->{'next'}) {
611 # Invalid hash, but still wants to go somewhere, take them to /
612 Redirect(RT->Config->Get('WebURL'));
615 return (1, HTML::Mason::Commands::loc('Logged in'));
619 =head2 LoadSessionFromCookie
621 Load or setup a session cookie for the current user.
625 sub _SessionCookieName {
626 my $cookiename = "RT_SID_" . RT->Config->Get('rtname');
627 $cookiename .= "." . $ENV{'SERVER_PORT'} if $ENV{'SERVER_PORT'};
631 sub LoadSessionFromCookie {
633 my %cookies = CGI::Cookie->fetch;
634 my $cookiename = _SessionCookieName();
635 my $SessionCookie = ( $cookies{$cookiename} ? $cookies{$cookiename}->value : undef );
636 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', $SessionCookie;
637 unless ( $SessionCookie && $HTML::Mason::Commands::session{'_session_id'} eq $SessionCookie ) {
638 undef $cookies{$cookiename};
640 if ( int RT->Config->Get('AutoLogoff') ) {
641 my $now = int( time / 60 );
642 my $last_update = $HTML::Mason::Commands::session{'_session_last_update'} || 0;
644 if ( $last_update && ( $now - $last_update - RT->Config->Get('AutoLogoff') ) > 0 ) {
645 InstantiateNewSession();
648 # save session on each request when AutoLogoff is turned on
649 $HTML::Mason::Commands::session{'_session_last_update'} = $now if $now != $last_update;
653 sub InstantiateNewSession {
654 tied(%HTML::Mason::Commands::session)->delete if tied(%HTML::Mason::Commands::session);
655 tie %HTML::Mason::Commands::session, 'RT::Interface::Web::Session', undef;
658 sub SendSessionCookie {
659 my $cookie = CGI::Cookie->new(
660 -name => _SessionCookieName(),
661 -value => $HTML::Mason::Commands::session{_session_id},
662 -path => RT->Config->Get('WebPath'),
663 -secure => ( RT->Config->Get('WebSecureCookies') ? 1 : 0 ),
664 -httponly => ( RT->Config->Get('WebHttpOnlyCookies') ? 1 : 0 ),
667 $HTML::Mason::Commands::r->err_headers_out->{'Set-Cookie'} = $cookie->as_string;
672 This routine ells the current user's browser to redirect to URL.
673 Additionally, it unties the user's currently active session, helping to avoid
674 A bug in Apache::Session 1.81 and earlier which clobbers sessions if we try to use
675 a cached DBI statement handle twice at the same time.
680 my $redir_to = shift;
681 untie $HTML::Mason::Commands::session;
682 my $uri = URI->new($redir_to);
683 my $server_uri = URI->new( RT->Config->Get('WebURL') );
685 # Make relative URIs absolute from the server host and scheme
686 $uri->scheme($server_uri->scheme) if not defined $uri->scheme;
687 if (not defined $uri->host) {
688 $uri->host($server_uri->host);
689 $uri->port($server_uri->port);
692 # If the user is coming in via a non-canonical
693 # hostname, don't redirect them to the canonical host,
694 # it will just upset them (and invalidate their credentials)
695 # don't do this if $RT::CanoniaclRedirectURLs is true
696 if ( !RT->Config->Get('CanonicalizeRedirectURLs')
697 && $uri->host eq $server_uri->host
698 && $uri->port eq $server_uri->port )
700 if ( defined $ENV{HTTPS} and $ENV{'HTTPS'} eq 'on' ) {
701 $uri->scheme('https');
703 $uri->scheme('http');
706 # [rt3.fsck.com #12716] Apache recommends use of $SERVER_HOST
707 $uri->host( $ENV{'SERVER_HOST'} || $ENV{'HTTP_HOST'} );
708 $uri->port( $ENV{'SERVER_PORT'} );
711 # not sure why, but on some systems without this call mason doesn't
712 # set status to 302, but 200 instead and people see blank pages
713 $HTML::Mason::Commands::r->status(302);
715 # Perlbal expects a status message, but Mason's default redirect status
716 # doesn't provide one. See also rt.cpan.org #36689.
717 $HTML::Mason::Commands::m->redirect( $uri->canonical, "302 Found" );
719 $HTML::Mason::Commands::m->abort;
722 =head2 StaticFileHeaders
724 Send the browser a few headers to try to get it to (somewhat agressively)
725 cache RT's static Javascript and CSS files.
727 This routine could really use _accurate_ heuristics. (XXX TODO)
731 sub StaticFileHeaders {
732 my $date = RT::Date->new($RT::SystemUser);
735 $HTML::Mason::Commands::r->headers_out->{'Cache-Control'} = 'max-age=259200, public';
737 # Expire things in a month.
738 $date->Set( Value => time + 30 * 24 * 60 * 60 );
739 $HTML::Mason::Commands::r->headers_out->{'Expires'} = $date->RFC2616;
741 # if we set 'Last-Modified' then browser request a comp using 'If-Modified-Since'
742 # request, but we don't handle it and generate full reply again
743 # Last modified at server start time
744 # $date->Set( Value => $^T );
745 # $HTML::Mason::Commands::r->headers_out->{'Last-Modified'} = $date->RFC2616;
750 Takes a C<< Path => path >> and returns a boolean indicating that
751 the path is safely within RT's control or not. The path I<must> be
754 This function does not consult the filesystem at all; it is merely
755 a logical sanity checking of the path. This explicitly does not handle
756 symlinks; if you have symlinks in RT's webroot pointing outside of it,
757 then we assume you know what you are doing.
764 my $path = $args{Path};
766 # Get File::Spec to clean up extra /s, ./, etc
767 my $cleaned_up = File::Spec->canonpath($path);
769 if (!defined($cleaned_up)) {
770 $RT::Logger->info("Rejecting path that canonpath doesn't understand: $path");
774 # Forbid too many ..s. We can't just sum then check because
775 # "../foo/bar/baz" should be illegal even though it has more
776 # downdirs than updirs. So as soon as we get a negative score
777 # (which means "breaking out" of the top level) we reject the path.
779 my @components = split '/', $cleaned_up;
781 for my $component (@components) {
782 if ($component eq '..') {
785 $RT::Logger->info("Rejecting unsafe path: $path");
789 elsif ($component eq '.' || $component eq '') {
790 # these two have no effect on $score
800 =head2 SendStaticFile
802 Takes a File => path and a Type => Content-type
804 If Type isn't provided and File is an image, it will
805 figure out a sane Content-type, otherwise it will
806 send application/octet-stream
808 Will set caching headers using StaticFileHeaders
815 my $file = $args{File};
816 my $type = $args{Type};
817 my $relfile = $args{RelativeFile};
819 if (defined($relfile) && !$self->PathIsSafe(Path => $relfile)) {
820 $HTML::Mason::Commands::r->status(400);
821 $HTML::Mason::Commands::m->abort;
824 $self->StaticFileHeaders();
827 if ( $file =~ /\.(gif|png|jpe?g)$/i ) {
829 $type =~ s/jpg/jpeg/gi;
831 $type ||= "application/octet-stream";
834 # CGI.pm version 3.51 and 3.52 bang charset=iso-8859-1 onto our JS
835 # since we don't specify a charset
836 if ( $type =~ m{application/javascript} &&
837 $type !~ m{charset=([\w-]+)$} ) {
838 $type .= "; charset=utf-8";
840 $HTML::Mason::Commands::r->content_type($type);
841 open( my $fh, '<', $file ) or die "couldn't open file: $!";
845 $HTML::Mason::Commands::m->out($_) while (<$fh>);
846 $HTML::Mason::Commands::m->flush_buffer;
853 my $content = $args{Content};
854 return '' unless $content;
856 # Make the content have no 'weird' newlines in it
857 $content =~ s/\r+\n/\n/g;
859 my $return_content = $content;
861 my $html = $args{ContentType} && $args{ContentType} eq "text/html";
862 my $sigonly = $args{StripSignature};
864 # massage content to easily detect if there's any real content
865 $content =~ s/\s+//g; # yes! remove all the spaces
867 # remove html version of spaces and newlines
868 $content =~ s! !!g;
869 $content =~ s!<br/?>!!g;
872 # Filter empty content when type is text/html
873 return '' if $html && $content !~ /\S/;
875 # If we aren't supposed to strip the sig, just bail now.
876 return $return_content unless $sigonly;
879 my $sig = $args{'CurrentUser'}->UserObj->Signature || '';
882 # Check for plaintext sig
883 return '' if not $html and $content =~ /^(--)?\Q$sig\E$/;
885 # Check for html-formatted sig; we don't use EscapeUTF8 here
886 # because we want to precisely match the escaping that FCKEditor
887 # uses. see also 311223f5, which fixed this for 4.0
894 and $content =~ m{^(?:<p>)?(--)?\Q$sig\E(?:</p>)?$}s;
897 return $return_content;
905 # if they've passed multiple values, they'll be an array. if they've
906 # passed just one, a scalar whatever they are, mark them as utf8
909 ? Encode::is_utf8($_)
911 : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ )
912 : ( $type eq 'ARRAY' )
913 ? [ map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) }
915 : ( $type eq 'HASH' )
916 ? { map { ( ref($_) or Encode::is_utf8($_) ) ? $_ : Encode::decode( 'UTF-8' => $_, Encode::FB_PERLQQ ) }
922 sub PreprocessTimeUpdates {
925 # Later in the code we use
926 # $m->comp( { base_comp => $m->request_comp }, $m->fetch_next, %ARGS );
927 # instead of $m->call_next to avoid problems with UTF8 keys in arguments.
928 # The call_next method pass through original arguments and if you have
929 # an argument with unicode key then in a next component you'll get two
930 # records in the args hash: one with key without UTF8 flag and another
931 # with the flag, which may result into errors. "{ base_comp => $m->request_comp }"
932 # is copied from mason's source to get the same results as we get from
933 # call_next method, this feature is not documented, so we just leave it
934 # here to avoid possible side effects.
936 # This code canonicalizes time inputs in hours into minutes
937 foreach my $field ( keys %$ARGS ) {
938 next unless $field =~ /^(.*)-TimeUnits$/i && $ARGS->{$1};
940 $ARGS->{$local} =~ s{\b (?: (\d+) \s+ )? (\d+)/(\d+) \b}
941 {($1 || 0) + $3 ? $2 / $3 : 0}xe;
942 if ( $ARGS->{$field} && $ARGS->{$field} =~ /hours/i ) {
943 $ARGS->{$local} *= 60;
945 delete $ARGS->{$field};
950 sub MaybeEnableSQLStatementLog {
952 my $log_sql_statements = RT->Config->Get('StatementLog');
954 if ($log_sql_statements) {
955 $RT::Handle->ClearSQLStatementLog;
956 $RT::Handle->LogSQLStatements(1);
961 sub LogRecordedSQLStatements {
962 my $log_sql_statements = RT->Config->Get('StatementLog');
964 return unless ($log_sql_statements);
966 my @log = $RT::Handle->SQLStatementLog;
967 $RT::Handle->ClearSQLStatementLog;
968 for my $stmt (@log) {
969 my ( $time, $sql, $bind, $duration ) = @{$stmt};
979 level => $log_sql_statements,
981 . sprintf( "%.6f", $duration )
983 . ( @bind ? " [ bound values: @{[map{qq|'$_'|} @bind]} ]" : "" )
989 package HTML::Mason::Commands;
991 use vars qw/$r $m %session/;
997 loc is a nice clean global routine which calls $session{'CurrentUser'}->loc()
998 with whatever it's called with. If there is no $session{'CurrentUser'},
999 it creates a temporary user, so we have something to get a localisation handle
1006 if ( $session{'CurrentUser'}
1007 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1009 return ( $session{'CurrentUser'}->loc(@_) );
1012 RT::CurrentUser->new();
1016 return ( $u->loc(@_) );
1019 # pathetic case -- SystemUser is gone.
1028 =head2 loc_fuzzy STRING
1030 loc_fuzzy is for handling localizations of messages that may already
1031 contain interpolated variables, typically returned from libraries
1032 outside RT's control. It takes the message string and extracts the
1033 variable array automatically by matching against the candidate entries
1034 inside the lexicon file.
1041 if ( $session{'CurrentUser'}
1042 && UNIVERSAL::can( $session{'CurrentUser'}, 'loc' ) )
1044 return ( $session{'CurrentUser'}->loc_fuzzy($msg) );
1046 my $u = RT::CurrentUser->new( $RT::SystemUser->Id );
1047 return ( $u->loc_fuzzy($msg) );
1054 # Error - calls Error and aborts
1059 if ( $session{'ErrorDocument'}
1060 && $session{'ErrorDocumentType'} )
1062 $r->content_type( $session{'ErrorDocumentType'} );
1063 $m->comp( $session{'ErrorDocument'}, Why => $why, %args );
1066 $m->comp( "/Elements/Error", Why => $why, %args );
1073 # {{{ sub CreateTicket
1075 =head2 CreateTicket ARGS
1077 Create a new ticket, using Mason's %ARGS. returns @results.
1086 my $Ticket = new RT::Ticket( $session{'CurrentUser'} );
1088 my $Queue = new RT::Queue( $session{'CurrentUser'} );
1089 unless ( $Queue->Load( $ARGS{'Queue'} ) ) {
1090 Abort('Queue not found');
1093 unless ( $Queue->CurrentUserHasRight('CreateTicket') ) {
1094 Abort('You have no permission to create tickets in that queue.');
1098 if ( defined $ARGS{'Due'} and $ARGS{'Due'} =~ /\S/ ) {
1099 $due = new RT::Date( $session{'CurrentUser'} );
1100 $due->Set( Format => 'unknown', Value => $ARGS{'Due'} );
1103 if ( defined $ARGS{'Starts'} and $ARGS{'Starts'} =~ /\S/ ) {
1104 $starts = new RT::Date( $session{'CurrentUser'} );
1105 $starts->Set( Format => 'unknown', Value => $ARGS{'Starts'} );
1108 my $sigless = RT::Interface::Web::StripContent(
1109 Content => $ARGS{Content},
1110 ContentType => $ARGS{ContentType},
1111 StripSignature => 1,
1112 CurrentUser => $session{'CurrentUser'},
1115 my $MIMEObj = MakeMIMEEntity(
1116 Subject => $ARGS{'Subject'},
1117 From => $ARGS{'From'},
1120 Type => $ARGS{'ContentType'},
1123 if ( $ARGS{'Attachments'} ) {
1124 my $rv = $MIMEObj->make_multipart;
1125 $RT::Logger->error("Couldn't make multipart message")
1126 if !$rv || $rv !~ /^(?:DONE|ALREADY)$/;
1128 foreach ( values %{ $ARGS{'Attachments'} } ) {
1130 $RT::Logger->error("Couldn't add empty attachemnt");
1133 $MIMEObj->add_part($_);
1137 foreach my $argument (qw(Encrypt Sign)) {
1138 $MIMEObj->head->add(
1139 "X-RT-$argument" => Encode::encode_utf8( $ARGS{$argument} )
1140 ) if defined $ARGS{$argument};
1144 Type => $ARGS{'Type'} || 'ticket',
1145 Queue => $ARGS{'Queue'},
1146 Owner => $ARGS{'Owner'},
1149 Requestor => $ARGS{'Requestors'},
1151 AdminCc => $ARGS{'AdminCc'},
1152 InitialPriority => $ARGS{'InitialPriority'},
1153 FinalPriority => $ARGS{'FinalPriority'},
1154 TimeLeft => $ARGS{'TimeLeft'},
1155 TimeEstimated => $ARGS{'TimeEstimated'},
1156 TimeWorked => $ARGS{'TimeWorked'},
1157 Subject => $ARGS{'Subject'},
1158 Status => $ARGS{'Status'},
1159 Due => $due ? $due->ISO : undef,
1160 Starts => $starts ? $starts->ISO : undef,
1165 foreach my $type (qw(Requestor Cc AdminCc)) {
1166 push @temp_squelch, map $_->address, Email::Address->parse( $create_args{$type} )
1167 if grep $_ eq $type || $_ eq ( $type . 's' ), @{ $ARGS{'SkipNotification'} || [] };
1171 if (@temp_squelch) {
1172 require RT::Action::SendEmail;
1173 RT::Action::SendEmail->SquelchMailTo( RT::Action::SendEmail->SquelchMailTo, @temp_squelch );
1176 if ( $ARGS{'AttachTickets'} ) {
1177 require RT::Action::SendEmail;
1178 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
1179 ref $ARGS{'AttachTickets'}
1180 ? @{ $ARGS{'AttachTickets'} }
1181 : ( $ARGS{'AttachTickets'} ) );
1184 foreach my $arg ( keys %ARGS ) {
1185 next if $arg =~ /-(?:Magic|Category)$/;
1187 if ( $arg =~ /^Object-RT::Transaction--CustomField-/ ) {
1188 $create_args{$arg} = $ARGS{$arg};
1191 # Object-RT::Ticket--CustomField-3-Values
1192 elsif ( $arg =~ /^Object-RT::Ticket--CustomField-(\d+)/ ) {
1195 my $cf = RT::CustomField->new( $session{'CurrentUser'} );
1197 unless ( $cf->id ) {
1198 $RT::Logger->error( "Couldn't load custom field #" . $cfid );
1202 if ( $arg =~ /-Upload$/ ) {
1203 $create_args{"CustomField-$cfid"} = _UploadedFile($arg);
1207 my $type = $cf->Type;
1210 if ( ref $ARGS{$arg} eq 'ARRAY' ) {
1211 @values = @{ $ARGS{$arg} };
1212 } elsif ( $type =~ /text/i ) {
1213 @values = ( $ARGS{$arg} );
1215 no warnings 'uninitialized';
1216 @values = split /\r*\n/, $ARGS{$arg};
1218 @values = grep length, map {
1224 grep defined, @values;
1226 $create_args{"CustomField-$cfid"} = \@values;
1230 # turn new link lists into arrays, and pass in the proper arguments
1232 'new-DependsOn' => 'DependsOn',
1233 'DependsOn-new' => 'DependedOnBy',
1234 'new-MemberOf' => 'Parents',
1235 'MemberOf-new' => 'Children',
1236 'new-RefersTo' => 'RefersTo',
1237 'RefersTo-new' => 'ReferredToBy',
1239 foreach my $key ( keys %map ) {
1240 next unless $ARGS{$key};
1241 $create_args{ $map{$key} } = [ grep $_, split ' ', $ARGS{$key} ];
1245 my ( $id, $Trans, $ErrMsg ) = $Ticket->Create(%create_args);
1250 push( @Actions, split( "\n", $ErrMsg ) );
1251 unless ( $Ticket->CurrentUserHasRight('ShowTicket') ) {
1252 Abort( "No permission to view newly created ticket #" . $Ticket->id . "." );
1254 return ( $Ticket, @Actions );
1260 # {{{ sub LoadTicket - loads a ticket
1262 =head2 LoadTicket id
1264 Takes a ticket id as its only variable. if it's handed an array, it takes
1267 Returns an RT::Ticket object as the current user.
1274 if ( ref($id) eq "ARRAY" ) {
1279 Abort("No ticket specified");
1282 my $Ticket = RT::Ticket->new( $session{'CurrentUser'} );
1284 unless ( $Ticket->id ) {
1285 Abort("Could not load ticket $id");
1292 # {{{ sub ProcessUpdateMessage
1294 =head2 ProcessUpdateMessage
1296 Takes paramhash with fields ARGSRef, TicketObj and SkipSignatureOnly.
1298 Don't write message if it only contains current user's signature and
1299 SkipSignatureOnly argument is true. Function anyway adds attachments
1300 and updates time worked field even if skips message. The default value
1305 sub ProcessUpdateMessage {
1310 SkipSignatureOnly => 1,
1314 if ( $args{ARGSRef}->{'UpdateAttachments'}
1315 && !keys %{ $args{ARGSRef}->{'UpdateAttachments'} } )
1317 delete $args{ARGSRef}->{'UpdateAttachments'};
1320 # Strip the signature
1321 $args{ARGSRef}->{UpdateContent} = RT::Interface::Web::StripContent(
1322 Content => $args{ARGSRef}->{UpdateContent},
1323 ContentType => $args{ARGSRef}->{UpdateContentType},
1324 StripSignature => $args{SkipSignatureOnly},
1325 CurrentUser => $args{'TicketObj'}->CurrentUser,
1328 # If, after stripping the signature, we have no message, move the
1329 # UpdateTimeWorked into adjusted TimeWorked, so that a later
1330 # ProcessBasics can deal -- then bail out.
1331 if ( not $args{ARGSRef}->{'UpdateAttachments'}
1332 and not length $args{ARGSRef}->{'UpdateContent'} )
1334 if ( $args{ARGSRef}->{'UpdateTimeWorked'} ) {
1335 $args{ARGSRef}->{TimeWorked} = $args{TicketObj}->TimeWorked + delete $args{ARGSRef}->{'UpdateTimeWorked'};
1340 if ( $args{ARGSRef}->{'UpdateSubject'} eq $args{'TicketObj'}->Subject ) {
1341 $args{ARGSRef}->{'UpdateSubject'} = undef;
1344 my $Message = MakeMIMEEntity(
1345 Subject => $args{ARGSRef}->{'UpdateSubject'},
1346 Body => $args{ARGSRef}->{'UpdateContent'},
1347 Type => $args{ARGSRef}->{'UpdateContentType'},
1350 $Message->head->add( 'Message-ID' => Encode::encode_utf8(
1351 RT::Interface::Email::GenMessageId( Ticket => $args{'TicketObj'} )
1353 my $old_txn = RT::Transaction->new( $session{'CurrentUser'} );
1354 if ( $args{ARGSRef}->{'QuoteTransaction'} ) {
1355 $old_txn->Load( $args{ARGSRef}->{'QuoteTransaction'} );
1357 $old_txn = $args{TicketObj}->Transactions->First();
1360 if ( my $msg = $old_txn->Message->First ) {
1361 RT::Interface::Email::SetInReplyTo(
1362 Message => $Message,
1367 if ( $args{ARGSRef}->{'UpdateAttachments'} ) {
1368 $Message->make_multipart;
1369 $Message->add_part($_) foreach values %{ $args{ARGSRef}->{'UpdateAttachments'} };
1372 if ( $args{ARGSRef}->{'AttachTickets'} ) {
1373 require RT::Action::SendEmail;
1374 RT::Action::SendEmail->AttachTickets( RT::Action::SendEmail->AttachTickets,
1375 ref $args{ARGSRef}->{'AttachTickets'}
1376 ? @{ $args{ARGSRef}->{'AttachTickets'} }
1377 : ( $args{ARGSRef}->{'AttachTickets'} ) );
1380 my $bcc = $args{ARGSRef}->{'UpdateBcc'};
1381 my $cc = $args{ARGSRef}->{'UpdateCc'};
1383 my %message_args = (
1385 BccMessageTo => $bcc,
1386 Sign => $args{ARGSRef}->{'Sign'},
1387 Encrypt => $args{ARGSRef}->{'Encrypt'},
1388 MIMEObj => $Message,
1389 TimeTaken => $args{ARGSRef}->{'UpdateTimeWorked'}
1393 foreach my $type (qw(Cc AdminCc)) {
1394 if (grep $_ eq $type || $_ eq ( $type . 's' ), @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
1395 push @temp_squelch, map $_->address, Email::Address->parse( $message_args{$type} );
1396 push @temp_squelch, $args{TicketObj}->$type->MemberEmailAddresses;
1397 push @temp_squelch, $args{TicketObj}->QueueObj->$type->MemberEmailAddresses;
1400 if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
1401 push @temp_squelch, map $_->address, Email::Address->parse( $message_args{Requestor} );
1402 push @temp_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses;
1405 if (@temp_squelch) {
1406 require RT::Action::SendEmail;
1407 RT::Action::SendEmail->SquelchMailTo( RT::Action::SendEmail->SquelchMailTo, @temp_squelch );
1410 unless ( $args{'ARGSRef'}->{'UpdateIgnoreAddressCheckboxes'} ) {
1411 foreach my $key ( keys %{ $args{ARGSRef} } ) {
1412 next unless $key =~ /^Update(Cc|Bcc)-(.*)$/;
1414 my $var = ucfirst($1) . 'MessageTo';
1416 if ( $message_args{$var} ) {
1417 $message_args{$var} .= ", $value";
1419 $message_args{$var} = $value;
1425 if ( $args{ARGSRef}->{'UpdateType'} =~ /^(private|public)$/ ) {
1426 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Comment(%message_args);
1427 push( @results, $Description );
1428 $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
1429 } elsif ( $args{ARGSRef}->{'UpdateType'} eq 'response' ) {
1430 my ( $Transaction, $Description, $Object ) = $args{TicketObj}->Correspond(%message_args);
1431 push( @results, $Description );
1432 $Object->UpdateCustomFields( ARGSRef => $args{ARGSRef} ) if $Object;
1435 loc("Update type was neither correspondence nor comment.") . " " . loc("Update not recorded.") );
1442 # {{{ sub MakeMIMEEntity
1444 =head2 MakeMIMEEntity PARAMHASH
1446 Takes a paramhash Subject, Body and AttachmentFieldName.
1448 Also takes Form, Cc and Type as optional paramhash keys.
1450 Returns a MIME::Entity.
1454 sub MakeMIMEEntity {
1456 #TODO document what else this takes.
1462 AttachmentFieldName => undef,
1466 my $Message = MIME::Entity->build(
1467 Type => 'multipart/mixed',
1468 map { $_ => Encode::encode_utf8( $args{ $_} ) }
1469 grep defined $args{$_}, qw(Subject From Cc)
1472 if ( defined $args{'Body'} && length $args{'Body'} ) {
1474 # Make the update content have no 'weird' newlines in it
1475 $args{'Body'} =~ s/\r\n/\n/gs;
1478 Type => $args{'Type'} || 'text/plain',
1480 Data => $args{'Body'},
1484 if ( $args{'AttachmentFieldName'} ) {
1486 my $cgi_object = $m->cgi_object;
1488 if ( my $filehandle = $cgi_object->upload( $args{'AttachmentFieldName'} ) ) {
1490 my ( @content, $buffer );
1491 while ( my $bytesread = read( $filehandle, $buffer, 4096 ) ) {
1492 push @content, $buffer;
1495 my $uploadinfo = $cgi_object->uploadInfo($filehandle);
1497 # Prefer the cached name first over CGI.pm stringification.
1498 my $filename = $RT::Mason::CGI::Filename;
1499 $filename = "$filehandle" unless defined $filename;
1500 $filename = Encode::encode_utf8( $filename );
1501 $filename =~ s{^.*[\\/]}{};
1504 Type => $uploadinfo->{'Content-Type'},
1505 Filename => $filename,
1508 if ( !$args{'Subject'} && !( defined $args{'Body'} && length $args{'Body'} ) ) {
1509 $Message->head->set( 'Subject' => $filename );
1514 $Message->make_singlepart;
1516 RT::I18N::SetMIMEEntityToUTF8($Message); # convert text parts into utf-8
1524 # {{{ sub ParseDateToISO
1526 =head2 ParseDateToISO
1528 Takes a date in an arbitrary format.
1529 Returns an ISO date and time in GMT
1533 sub ParseDateToISO {
1536 my $date_obj = RT::Date->new( $session{'CurrentUser'} );
1538 Format => 'unknown',
1541 return ( $date_obj->ISO );
1546 # {{{ sub ProcessACLChanges
1548 sub ProcessACLChanges {
1549 my $ARGSref = shift;
1551 #XXX: why don't we get ARGSref like in other Process* subs?
1555 foreach my $arg ( keys %$ARGSref ) {
1556 next unless ( $arg =~ /^(GrantRight|RevokeRight)-(\d+)-(.+?)-(\d+)$/ );
1558 my ( $method, $principal_id, $object_type, $object_id ) = ( $1, $2, $3, $4 );
1561 if ( UNIVERSAL::isa( $ARGSref->{$arg}, 'ARRAY' ) ) {
1562 @rights = @{ $ARGSref->{$arg} };
1564 @rights = $ARGSref->{$arg};
1566 @rights = grep $_, @rights;
1567 next unless @rights;
1569 my $principal = RT::Principal->new( $session{'CurrentUser'} );
1570 $principal->Load($principal_id);
1573 if ( $object_type eq 'RT::System' ) {
1575 } elsif ( $RT::ACE::OBJECT_TYPES{$object_type} ) {
1576 $obj = $object_type->new( $session{'CurrentUser'} );
1577 $obj->Load($object_id);
1578 unless ( $obj->id ) {
1579 $RT::Logger->error("couldn't load $object_type #$object_id");
1583 $RT::Logger->error("object type '$object_type' is incorrect");
1584 push( @results, loc("System Error") . ': ' . loc( "Rights could not be granted for [_1]", $object_type ) );
1588 foreach my $right (@rights) {
1589 my ( $val, $msg ) = $principal->$method( Object => $obj, Right => $right );
1590 push( @results, $msg );
1599 # {{{ sub UpdateRecordObj
1601 =head2 UpdateRecordObj ( ARGSRef => \%ARGS, Object => RT::Record, AttributesRef => \@attribs)
1603 @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.
1605 Returns an array of success/failure messages
1609 sub UpdateRecordObject {
1612 AttributesRef => undef,
1614 AttributePrefix => undef,
1618 my $Object = $args{'Object'};
1619 my @results = $Object->Update(
1620 AttributesRef => $args{'AttributesRef'},
1621 ARGSRef => $args{'ARGSRef'},
1622 AttributePrefix => $args{'AttributePrefix'},
1630 # {{{ Sub ProcessCustomFieldUpdates
1632 sub ProcessCustomFieldUpdates {
1634 CustomFieldObj => undef,
1639 my $Object = $args{'CustomFieldObj'};
1640 my $ARGSRef = $args{'ARGSRef'};
1642 my @attribs = qw(Name Type Description Queue SortOrder);
1643 my @results = UpdateRecordObject(
1644 AttributesRef => \@attribs,
1649 my $prefix = "CustomField-" . $Object->Id;
1650 if ( $ARGSRef->{"$prefix-AddValue-Name"} ) {
1651 my ( $addval, $addmsg ) = $Object->AddValue(
1652 Name => $ARGSRef->{"$prefix-AddValue-Name"},
1653 Description => $ARGSRef->{"$prefix-AddValue-Description"},
1654 SortOrder => $ARGSRef->{"$prefix-AddValue-SortOrder"},
1656 push( @results, $addmsg );
1660 = ( ref $ARGSRef->{"$prefix-DeleteValue"} eq 'ARRAY' )
1661 ? @{ $ARGSRef->{"$prefix-DeleteValue"} }
1662 : ( $ARGSRef->{"$prefix-DeleteValue"} );
1664 foreach my $id (@delete_values) {
1665 next unless defined $id;
1666 my ( $err, $msg ) = $Object->DeleteValue($id);
1667 push( @results, $msg );
1670 my $vals = $Object->Values();
1671 while ( my $cfv = $vals->Next() ) {
1672 if ( my $so = $ARGSRef->{ "$prefix-SortOrder" . $cfv->Id } ) {
1673 if ( $cfv->SortOrder != $so ) {
1674 my ( $err, $msg ) = $cfv->SetSortOrder($so);
1675 push( @results, $msg );
1685 # {{{ sub ProcessTicketBasics
1687 =head2 ProcessTicketBasics ( TicketObj => $Ticket, ARGSRef => \%ARGS );
1689 Returns an array of results messages.
1693 sub ProcessTicketBasics {
1701 my $TicketObj = $args{'TicketObj'};
1702 my $ARGSRef = $args{'ARGSRef'};
1704 # {{{ Set basic fields
1717 if ( $ARGSRef->{'Queue'} and ( $ARGSRef->{'Queue'} !~ /^(\d+)$/ ) ) {
1718 my $tempqueue = RT::Queue->new($RT::SystemUser);
1719 $tempqueue->Load( $ARGSRef->{'Queue'} );
1720 if ( $tempqueue->id ) {
1721 $ARGSRef->{'Queue'} = $tempqueue->id;
1725 # Status isn't a field that can be set to a null value.
1726 # RT core complains if you try
1727 delete $ARGSRef->{'Status'} unless $ARGSRef->{'Status'};
1729 my @results = UpdateRecordObject(
1730 AttributesRef => \@attribs,
1731 Object => $TicketObj,
1732 ARGSRef => $ARGSRef,
1735 # We special case owner changing, so we can use ForceOwnerChange
1736 if ( $ARGSRef->{'Owner'} && ( $TicketObj->Owner != $ARGSRef->{'Owner'} ) ) {
1738 if ( $ARGSRef->{'ForceOwnerChange'} ) {
1739 $ChownType = "Force";
1741 $ChownType = "Give";
1744 my ( $val, $msg ) = $TicketObj->SetOwner( $ARGSRef->{'Owner'}, $ChownType );
1745 push( @results, $msg );
1755 sub ProcessTicketCustomFieldUpdates {
1757 $args{'Object'} = delete $args{'TicketObj'};
1758 my $ARGSRef = { %{ $args{'ARGSRef'} } };
1760 # Build up a list of objects that we want to work with
1761 my %custom_fields_to_mod;
1762 foreach my $arg ( keys %$ARGSRef ) {
1763 if ( $arg =~ /^Ticket-(\d+-.*)/ ) {
1764 $ARGSRef->{"Object-RT::Ticket-$1"} = delete $ARGSRef->{$arg};
1765 } elsif ( $arg =~ /^CustomField-(\d+-.*)/ ) {
1766 $ARGSRef->{"Object-RT::Ticket--$1"} = delete $ARGSRef->{$arg};
1770 return ProcessObjectCustomFieldUpdates( %args, ARGSRef => $ARGSRef );
1773 sub ProcessObjectCustomFieldUpdates {
1775 my $ARGSRef = $args{'ARGSRef'};
1778 # Build up a list of objects that we want to work with
1779 my %custom_fields_to_mod;
1780 foreach my $arg ( keys %$ARGSRef ) {
1782 # format: Object-<object class>-<object id>-CustomField-<CF id>-<commands>
1783 next unless $arg =~ /^Object-([\w:]+)-(\d*)-CustomField-(\d+)-(.*)$/;
1785 # For each of those objects, find out what custom fields we want to work with.
1786 $custom_fields_to_mod{$1}{ $2 || 0 }{$3}{$4} = $ARGSRef->{$arg};
1789 # For each of those objects
1790 foreach my $class ( keys %custom_fields_to_mod ) {
1791 foreach my $id ( keys %{ $custom_fields_to_mod{$class} } ) {
1792 my $Object = $args{'Object'};
1793 $Object = $class->new( $session{'CurrentUser'} )
1794 unless $Object && ref $Object eq $class;
1796 $Object->Load($id) unless ( $Object->id || 0 ) == $id;
1797 unless ( $Object->id ) {
1798 $RT::Logger->warning("Couldn't load object $class #$id");
1802 foreach my $cf ( keys %{ $custom_fields_to_mod{$class}{$id} } ) {
1803 my $CustomFieldObj = RT::CustomField->new( $session{'CurrentUser'} );
1804 $CustomFieldObj->LoadById($cf);
1805 unless ( $CustomFieldObj->id ) {
1806 $RT::Logger->warning("Couldn't load custom field #$cf");
1810 _ProcessObjectCustomFieldUpdates(
1811 Prefix => "Object-$class-$id-CustomField-$cf-",
1813 CustomField => $CustomFieldObj,
1814 ARGS => $custom_fields_to_mod{$class}{$id}{$cf},
1822 sub _ProcessObjectCustomFieldUpdates {
1824 my $cf = $args{'CustomField'};
1825 my $cf_type = $cf->Type;
1827 # Remove blank Values since the magic field will take care of this. Sometimes
1828 # the browser gives you a blank value which causes CFs to be processed twice
1829 if ( defined $args{'ARGS'}->{'Values'}
1830 && !length $args{'ARGS'}->{'Values'}
1831 && $args{'ARGS'}->{'Values-Magic'} )
1833 delete $args{'ARGS'}->{'Values'};
1837 foreach my $arg ( keys %{ $args{'ARGS'} } ) {
1839 # skip category argument
1840 next if $arg eq 'Category';
1842 # since http won't pass in a form element with a null value, we need
1844 if ( $arg eq 'Values-Magic' ) {
1846 # We don't care about the magic, if there's really a values element;
1847 next if defined $args{'ARGS'}->{'Value'} && length $args{'ARGS'}->{'Value'};
1848 next if defined $args{'ARGS'}->{'Values'} && length $args{'ARGS'}->{'Values'};
1850 # "Empty" values does not mean anything for Image and Binary fields
1851 next if $cf_type =~ /^(?:Image|Binary)$/;
1854 $args{'ARGS'}->{'Values'} = undef;
1858 if ( ref $args{'ARGS'}->{$arg} eq 'ARRAY' ) {
1859 @values = @{ $args{'ARGS'}->{$arg} };
1860 } elsif ( $cf_type =~ /text/i ) { # Both Text and Wikitext
1861 @values = ( $args{'ARGS'}->{$arg} );
1863 @values = split /\r*\n/, $args{'ARGS'}->{$arg}
1864 if defined $args{'ARGS'}->{$arg};
1866 @values = grep length, map {
1872 grep defined, @values;
1874 if ( $arg eq 'AddValue' || $arg eq 'Value' ) {
1875 foreach my $value (@values) {
1876 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
1880 push( @results, $msg );
1882 } elsif ( $arg eq 'Upload' ) {
1883 my $value_hash = _UploadedFile( $args{'Prefix'} . $arg ) or next;
1884 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue( %$value_hash, Field => $cf, );
1885 push( @results, $msg );
1886 } elsif ( $arg eq 'DeleteValues' ) {
1887 foreach my $value (@values) {
1888 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
1892 push( @results, $msg );
1894 } elsif ( $arg eq 'DeleteValueIds' ) {
1895 foreach my $value (@values) {
1896 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
1900 push( @results, $msg );
1902 } elsif ( $arg eq 'Values' && !$cf->Repeated ) {
1903 my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id );
1906 foreach my $value (@values) {
1907 if ( my $entry = $cf_values->HasEntry($value) ) {
1908 $values_hash{ $entry->id } = 1;
1912 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
1916 push( @results, $msg );
1917 $values_hash{$val} = 1 if $val;
1920 $cf_values->RedoSearch;
1921 while ( my $cf_value = $cf_values->Next ) {
1922 next if $values_hash{ $cf_value->id };
1924 my ( $val, $msg ) = $args{'Object'}->DeleteCustomFieldValue(
1926 ValueId => $cf_value->id
1928 push( @results, $msg );
1930 } elsif ( $arg eq 'Values' ) {
1931 my $cf_values = $args{'Object'}->CustomFieldValues( $cf->id );
1933 # keep everything up to the point of difference, delete the rest
1935 foreach my $old_cf ( @{ $cf_values->ItemsArrayRef } ) {
1936 if ( !$delete_flag and @values and $old_cf->Content eq $values[0] ) {
1945 # now add/replace extra things, if any
1946 foreach my $value (@values) {
1947 my ( $val, $msg ) = $args{'Object'}->AddCustomFieldValue(
1951 push( @results, $msg );
1956 loc("User asked for an unknown update type for custom field [_1] for [_2] object #[_3]",
1957 $cf->Name, ref $args{'Object'},
1966 # {{{ sub ProcessTicketWatchers
1968 =head2 ProcessTicketWatchers ( TicketObj => $Ticket, ARGSRef => \%ARGS );
1970 Returns an array of results messages.
1974 sub ProcessTicketWatchers {
1982 my $Ticket = $args{'TicketObj'};
1983 my $ARGSRef = $args{'ARGSRef'};
1987 foreach my $key ( keys %$ARGSRef ) {
1989 # Delete deletable watchers
1990 if ( $key =~ /^Ticket-DeleteWatcher-Type-(.*)-Principal-(\d+)$/ ) {
1991 my ( $code, $msg ) = $Ticket->DeleteWatcher(
1995 push @results, $msg;
1998 # Delete watchers in the simple style demanded by the bulk manipulator
1999 elsif ( $key =~ /^Delete(Requestor|Cc|AdminCc)$/ ) {
2000 my ( $code, $msg ) = $Ticket->DeleteWatcher(
2001 Email => $ARGSRef->{$key},
2004 push @results, $msg;
2007 # Add new wathchers by email address
2008 elsif ( ( $ARGSRef->{$key} || '' ) =~ /^(?:AdminCc|Cc|Requestor)$/
2009 and $key =~ /^WatcherTypeEmail(\d*)$/ )
2012 #They're in this order because otherwise $1 gets clobbered :/
2013 my ( $code, $msg ) = $Ticket->AddWatcher(
2014 Type => $ARGSRef->{$key},
2015 Email => $ARGSRef->{ "WatcherAddressEmail" . $1 }
2017 push @results, $msg;
2020 #Add requestors in the simple style demanded by the bulk manipulator
2021 elsif ( $key =~ /^Add(Requestor|Cc|AdminCc)$/ ) {
2022 my ( $code, $msg ) = $Ticket->AddWatcher(
2024 Email => $ARGSRef->{$key}
2026 push @results, $msg;
2029 # Add new watchers by owner
2030 elsif ( $key =~ /^Ticket-AddWatcher-Principal-(\d*)$/ ) {
2031 my $principal_id = $1;
2032 my $form = $ARGSRef->{$key};
2033 foreach my $value ( ref($form) ? @{$form} : ($form) ) {
2034 next unless $value =~ /^(?:AdminCc|Cc|Requestor)$/i;
2036 my ( $code, $msg ) = $Ticket->AddWatcher(
2038 PrincipalId => $principal_id
2040 push @results, $msg;
2050 # {{{ sub ProcessTicketDates
2052 =head2 ProcessTicketDates ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2054 Returns an array of results messages.
2058 sub ProcessTicketDates {
2065 my $Ticket = $args{'TicketObj'};
2066 my $ARGSRef = $args{'ARGSRef'};
2070 # {{{ Set date fields
2071 my @date_fields = qw(
2079 #Run through each field in this list. update the value if apropriate
2080 foreach my $field (@date_fields) {
2081 next unless exists $ARGSRef->{ $field . '_Date' };
2082 next if $ARGSRef->{ $field . '_Date' } eq '';
2086 my $DateObj = RT::Date->new( $session{'CurrentUser'} );
2088 Format => 'unknown',
2089 Value => $ARGSRef->{ $field . '_Date' }
2092 my $obj = $field . "Obj";
2093 if ( ( defined $DateObj->Unix )
2094 and ( $DateObj->Unix != $Ticket->$obj()->Unix() ) )
2096 my $method = "Set$field";
2097 my ( $code, $msg ) = $Ticket->$method( $DateObj->ISO );
2098 push @results, "$msg";
2108 # {{{ sub ProcessTicketLinks
2110 =head2 ProcessTicketLinks ( TicketObj => $Ticket, ARGSRef => \%ARGS );
2112 Returns an array of results messages.
2116 sub ProcessTicketLinks {
2123 my $Ticket = $args{'TicketObj'};
2124 my $ARGSRef = $args{'ARGSRef'};
2126 my (@results) = ProcessRecordLinks( RecordObj => $Ticket, ARGSRef => $ARGSRef );
2128 #Merge if we need to
2129 if ( $ARGSRef->{ $Ticket->Id . "-MergeInto" } ) {
2130 $ARGSRef->{ $Ticket->Id . "-MergeInto" } =~ s/\s+//g;
2131 my ( $val, $msg ) = $Ticket->MergeInto( $ARGSRef->{ $Ticket->Id . "-MergeInto" } );
2132 push @results, $msg;
2140 sub ProcessRecordLinks {
2147 my $Record = $args{'RecordObj'};
2148 my $ARGSRef = $args{'ARGSRef'};
2152 # Delete links that are gone gone gone.
2153 foreach my $arg ( keys %$ARGSRef ) {
2154 if ( $arg =~ /DeleteLink-(.*?)-(DependsOn|MemberOf|RefersTo)-(.*)$/ ) {
2159 my ( $val, $msg ) = $Record->DeleteLink(
2165 push @results, $msg;
2171 my @linktypes = qw( DependsOn MemberOf RefersTo );
2173 foreach my $linktype (@linktypes) {
2174 if ( $ARGSRef->{ $Record->Id . "-$linktype" } ) {
2175 $ARGSRef->{ $Record->Id . "-$linktype" } = join( ' ', @{ $ARGSRef->{ $Record->Id . "-$linktype" } } )
2176 if ref( $ARGSRef->{ $Record->Id . "-$linktype" } );
2178 for my $luri ( split( / /, $ARGSRef->{ $Record->Id . "-$linktype" } ) ) {
2180 $luri =~ s/\s+$//; # Strip trailing whitespace
2181 my ( $val, $msg ) = $Record->AddLink(
2185 push @results, $msg;
2188 if ( $ARGSRef->{ "$linktype-" . $Record->Id } ) {
2189 $ARGSRef->{ "$linktype-" . $Record->Id } = join( ' ', @{ $ARGSRef->{ "$linktype-" . $Record->Id } } )
2190 if ref( $ARGSRef->{ "$linktype-" . $Record->Id } );
2192 for my $luri ( split( / /, $ARGSRef->{ "$linktype-" . $Record->Id } ) ) {
2194 my ( $val, $msg ) = $Record->AddLink(
2199 push @results, $msg;
2207 =head2 _UploadedFile ( $arg );
2209 Takes a CGI parameter name; if a file is uploaded under that name,
2210 return a hash reference suitable for AddCustomFieldValue's use:
2211 C<( Value => $filename, LargeContent => $content, ContentType => $type )>.
2213 Returns C<undef> if no files were uploaded in the C<$arg> field.
2219 my $cgi_object = $m->cgi_object;
2220 my $fh = $cgi_object->upload($arg) or return undef;
2221 my $upload_info = $cgi_object->uploadInfo($fh);
2223 my $filename = "$fh";
2224 $filename =~ s#^.*[\\/]##;
2229 LargeContent => do { local $/; scalar <$fh> },
2230 ContentType => $upload_info->{'Content-Type'},
2234 sub GetColumnMapEntry {
2235 my %args = ( Map => {}, Name => '', Attribute => undef, @_ );
2237 # deal with the simplest thing first
2238 if ( $args{'Map'}{ $args{'Name'} } ) {
2239 return $args{'Map'}{ $args{'Name'} }{ $args{'Attribute'} };
2243 elsif ( my ( $mainkey, $subkey ) = $args{'Name'} =~ /^(.*?)\.{(.+)}$/ ) {
2244 return undef unless $args{'Map'}->{$mainkey};
2245 return $args{'Map'}{$mainkey}{ $args{'Attribute'} }
2246 unless ref $args{'Map'}{$mainkey}{ $args{'Attribute'} } eq 'CODE';
2248 return sub { $args{'Map'}{$mainkey}{ $args{'Attribute'} }->( @_, $subkey ) };
2253 sub ProcessColumnMapValue {
2255 my %args = ( Arguments => [], Escape => 1, @_ );
2258 if ( UNIVERSAL::isa( $value, 'CODE' ) ) {
2259 my @tmp = $value->( @{ $args{'Arguments'} } );
2260 return ProcessColumnMapValue( ( @tmp > 1 ? \@tmp : $tmp[0] ), %args );
2261 } elsif ( UNIVERSAL::isa( $value, 'ARRAY' ) ) {
2262 return join '', map ProcessColumnMapValue( $_, %args ), @$value;
2263 } elsif ( UNIVERSAL::isa( $value, 'SCALAR' ) ) {
2268 return $m->interp->apply_escapes( $value, 'h' ) if $args{'Escape'};
2272 =head2 _load_container_object ( $type, $id );
2274 Instantiate container object for saving searches.
2278 sub _load_container_object {
2279 my ( $obj_type, $obj_id ) = @_;
2280 return RT::SavedSearch->new( $session{'CurrentUser'} )->_load_privacy_object( $obj_type, $obj_id );
2283 =head2 _parse_saved_search ( $arg );
2285 Given a serialization string for saved search, and returns the
2286 container object and the search id.
2290 sub _parse_saved_search {
2292 return unless $spec;
2293 if ( $spec !~ /^(.*?)-(\d+)-SavedSearch-(\d+)$/ ) {
2300 return ( _load_container_object( $obj_type, $obj_id ), $search_id );
2303 package RT::Interface::Web;
2304 RT::Base->_ImportOverlays();