diff options
Diffstat (limited to 'Bugzilla')
90 files changed, 8282 insertions, 954 deletions
diff --git a/Bugzilla/Attachment.pm b/Bugzilla/Attachment.pm index 69939a657..1a12e65b1 100644 --- a/Bugzilla/Attachment.pm +++ b/Bugzilla/Attachment.pm @@ -22,10 +22,11 @@ # Marc Schumann <wurblzap@gmail.com> # Frédéric Buclin <LpSolit@gmail.com> -use strict; - package Bugzilla::Attachment; +use 5.10.0; +use strict; + =head1 NAME Bugzilla::Attachment - Bugzilla attachment class. @@ -75,22 +76,19 @@ use constant LIST_ORDER => ID_FIELD; use constant AUDIT_CREATES => 0; use constant AUDIT_UPDATES => 0; -sub DB_COLUMNS { - my $dbh = Bugzilla->dbh; - - return qw( - attach_id - bug_id - description - filename - isobsolete - ispatch - isprivate - mimetype - modification_time - submitter_id), - $dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i') . ' AS creation_ts'; -} +use constant DB_COLUMNS => qw( + attach_id + bug_id + creation_ts + description + filename + isobsolete + ispatch + isprivate + mimetype + modification_time + submitter_id +); use constant REQUIRED_FIELD_MAP => { bug_id => 'bug', @@ -142,8 +140,7 @@ the ID of the bug to which the attachment is attached =cut sub bug_id { - my $self = shift; - return $self->{bug_id}; + return $_[0]->{bug_id}; } =over @@ -157,11 +154,8 @@ the bug object to which the attachment is attached =cut sub bug { - my $self = shift; - require Bugzilla::Bug; - $self->{bug} ||= Bugzilla::Bug->new($self->bug_id); - return $self->{bug}; + return $_[0]->{bug} //= Bugzilla::Bug->new({ id => $_[0]->bug_id, cache => 1 }); } =over @@ -175,8 +169,7 @@ user-provided text describing the attachment =cut sub description { - my $self = shift; - return $self->{description}; + return $_[0]->{description}; } =over @@ -190,8 +183,7 @@ the attachment's MIME media type =cut sub contenttype { - my $self = shift; - return $self->{mimetype}; + return $_[0]->{mimetype}; } =over @@ -205,10 +197,8 @@ the user who attached the attachment =cut sub attacher { - my $self = shift; - return $self->{attacher} if exists $self->{attacher}; - $self->{attacher} = new Bugzilla::User($self->{submitter_id}); - return $self->{attacher}; + return $_[0]->{attacher} + //= new Bugzilla::User({ id => $_[0]->{submitter_id}, cache => 1 }); } =over @@ -222,8 +212,7 @@ the date and time on which the attacher attached the attachment =cut sub attached { - my $self = shift; - return $self->{creation_ts}; + return $_[0]->{creation_ts}; } =over @@ -237,8 +226,7 @@ the date and time on which the attachment was last modified. =cut sub modification_time { - my $self = shift; - return $self->{modification_time}; + return $_[0]->{modification_time}; } =over @@ -252,8 +240,7 @@ the name of the file the attacher attached =cut sub filename { - my $self = shift; - return $self->{filename}; + return $_[0]->{filename}; } =over @@ -267,8 +254,7 @@ whether or not the attachment is a patch =cut sub ispatch { - my $self = shift; - return $self->{ispatch}; + return $_[0]->{ispatch}; } =over @@ -282,8 +268,7 @@ whether or not the attachment is obsolete =cut sub isobsolete { - my $self = shift; - return $self->{isobsolete}; + return $_[0]->{isobsolete}; } =over @@ -297,8 +282,7 @@ whether or not the attachment is private =cut sub isprivate { - my $self = shift; - return $self->{isprivate}; + return $_[0]->{isprivate}; } =over @@ -315,8 +299,7 @@ matches, because this will return a value even if it's matched by the generic =cut sub is_viewable { - my $self = shift; - my $contenttype = $self->contenttype; + my $contenttype = $_[0]->contenttype; my $cgi = Bugzilla->cgi; # We assume we can view all text and image types. @@ -390,7 +373,7 @@ the length (in characters) of the attachment content sub datasize { my $self = shift; - return $self->{datasize} if exists $self->{datasize}; + return $self->{datasize} if defined $self->{datasize}; # If we have already retrieved the data, return its size. return length($self->{data}) if exists $self->{data}; @@ -415,6 +398,53 @@ sub datasize { return $self->{datasize}; } +=over + +=item C<linecount> + +the number of lines of the attachment content + +=back + +=cut + +# linecount allows for getting the number of lines of an attachment +# from the database directly if the data has not yet been loaded for +# performance reasons. + +sub linecount { + my ($self) = @_; + + return $self->{linecount} if exists $self->{linecount}; + + # Limit this to just text/* attachments as this could + # cause strange results for binary attachments. + return if $self->contenttype !~ /^text\//; + + # If the data has already been loaded, we can just determine + # line count from the data directly. + if ($self->{data}) { + $self->{linecount} = $self->{data} =~ tr/\n/\n/; + } + else { + $self->{linecount} = + int(Bugzilla->dbh->selectrow_array(" + SELECT LENGTH(attach_data.thedata)-LENGTH(REPLACE(attach_data.thedata,'\n',''))/LENGTH('\n') + FROM attach_data WHERE id = ?", undef, $self->id)); + + } + + # If we still do not have a linecount either the attachment + # is stored in a local file or has been deleted. If the former, + # we call self->data to force a load from the filesystem and + # then do a split on newlines and count again. + unless ($self->{linecount}) { + $self->{linecount} = $self->data =~ tr/\n/\n/; + } + + return $self->{linecount}; +} + sub _get_local_filename { my $self = shift; my $hash = ($self->id % 100) + 100; @@ -433,11 +463,8 @@ flags that have been set on the attachment =cut sub flags { - my $self = shift; - # Don't cache it as it must be in sync with ->flag_types. - $self->{flags} = [map { @{$_->{flags}} } @{$self->flag_types}]; - return $self->{flags}; + return $_[0]->{flags} = [map { @{$_->{flags}} } @{$_[0]->flag_types}]; } =over @@ -458,10 +485,10 @@ sub flag_types { my $vars = { target_type => 'attachment', product_id => $self->bug->product_id, component_id => $self->bug->component_id, - attach_id => $self->id }; + attach_id => $self->id, + active_or_has_flags => $self->bug_id }; - $self->{flag_types} = Bugzilla::Flag->_flag_types($vars); - return $self->{flag_types}; + return $self->{flag_types} = Bugzilla::Flag->_flag_types($vars); } ############################### @@ -573,7 +600,7 @@ sub _check_filename { else { ThrowUserError('file_not_specified'); } - } + } # Remove path info (if any) from the file name. The browser should do this # for us, but some are buggy. This may not work on Mac file names and could @@ -656,6 +683,24 @@ sub get_attachments_by_bug { push(@{$att{$_->attach_id}->{flags}}, $_) foreach @$flags; $attachments = [sort {$a->id <=> $b->id} values %att]; + + # Preload attachers. + my %user_ids = map { $_->{submitter_id} => 1 } @$attachments; + my $users = Bugzilla::User->new_from_list([keys %user_ids]); + my %user_map = map { $_->id => $_ } @$users; + foreach my $attachment (@$attachments) { + $attachment->{attacher} = $user_map{$attachment->{submitter_id}}; + } + + # Preload datasizes. + my $sizes = + $dbh->selectall_hashref('SELECT attach_id, LENGTH(thedata) AS datasize + FROM attachments LEFT JOIN attach_data ON attach_id = id + WHERE bug_id = ?', + 'attach_id', undef, $bug_id); + + # Force the size of attachments not in the DB to be recalculated. + $_->{datasize} = $sizes->{$_->id}->{datasize} || undef foreach @$attachments; } return $attachments; } @@ -865,10 +910,11 @@ sub update { } if (scalar(keys %$changes)) { - $dbh->do('UPDATE attachments SET modification_time = ? WHERE attach_id = ?', - undef, ($timestamp, $self->id)); - $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?', - undef, ($timestamp, $self->bug_id)); + $dbh->do('UPDATE attachments SET modification_time = ? WHERE attach_id = ?', + undef, ($timestamp, $self->id)); + $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?', + undef, ($timestamp, $self->bug_id)); + $self->{modification_time} = $timestamp; } return $changes; diff --git a/Bugzilla/Attachment/PatchReader.pm b/Bugzilla/Attachment/PatchReader.pm index cfc7610f4..7c5dfc470 100644 --- a/Bugzilla/Attachment/PatchReader.pm +++ b/Bugzilla/Attachment/PatchReader.pm @@ -19,6 +19,9 @@ use strict; package Bugzilla::Attachment::PatchReader; +use IPC::Open3; +use Symbol 'gensym'; + use Bugzilla::Error; use Bugzilla::Attachment; use Bugzilla::Util; @@ -33,8 +36,8 @@ sub process_diff { my ($reader, $last_reader) = setup_patch_readers(undef, $context); if ($format eq 'raw') { - require PatchReader::DiffPrinter::raw; - $last_reader->sends_data_to(new PatchReader::DiffPrinter::raw()); + require Bugzilla::PatchReader::DiffPrinter::raw; + $last_reader->sends_data_to(new Bugzilla::PatchReader::DiffPrinter::raw()); # Actually print out the patch. print $cgi->header(-type => 'text/plain', -expires => '+3M'); @@ -109,13 +112,37 @@ sub process_interdiff { # Send through interdiff, send output directly to template. # Must hack path so that interdiff will work. $ENV{'PATH'} = $lc->{diffpath}; - open my $interdiff_fh, "$lc->{interdiffbin} $old_filename $new_filename|"; - binmode $interdiff_fh; + + my ($pid, $interdiff_stdout, $interdiff_stderr); + if ($ENV{MOD_PERL}) { + require Apache2::RequestUtil; + require Apache2::SubProcess; + my $request = Apache2::RequestUtil->request; + (undef, $interdiff_stdout, $interdiff_stderr) = $request->spawn_proc_prog( + $lc->{interdiffbin}, [$old_filename, $new_filename] + ); + } else { + $interdiff_stderr = gensym; + my $pid = open3(gensym, $interdiff_stdout, $interdiff_stderr, + $lc->{interdiffbin}, $old_filename, $new_filename); + } + binmode $interdiff_stdout; + + # Check for errors + { + local $/ = undef; + my $error = <$interdiff_stderr>; + if ($error) { + warn($error); + $warning = 'interdiff3'; + } + } + my ($reader, $last_reader) = setup_patch_readers("", $context); if ($format eq 'raw') { - require PatchReader::DiffPrinter::raw; - $last_reader->sends_data_to(new PatchReader::DiffPrinter::raw()); + require Bugzilla::PatchReader::DiffPrinter::raw; + $last_reader->sends_data_to(new Bugzilla::PatchReader::DiffPrinter::raw()); # Actually print out the patch. print $cgi->header(-type => 'text/plain', -expires => '+3M'); @@ -123,7 +150,7 @@ sub process_interdiff { } else { # In case the HTML page is displayed with the UTF-8 encoding. - binmode $interdiff_fh, ':utf8' if Bugzilla->params->{'utf8'}; + binmode $interdiff_stdout, ':utf8' if Bugzilla->params->{'utf8'}; $vars->{'warning'} = $warning if $warning; $vars->{'bugid'} = $new_attachment->bug_id; @@ -134,9 +161,9 @@ sub process_interdiff { setup_template_patch_reader($last_reader, $format, $context, $vars); } - $reader->iterate_fh($interdiff_fh, 'interdiff #' . $old_attachment->id . + $reader->iterate_fh($interdiff_stdout, 'interdiff #' . $old_attachment->id . ' #' . $new_attachment->id); - close $interdiff_fh; + waitpid($pid, 0) if $pid; $ENV{'PATH'} = ''; # Delete temporary files. @@ -152,29 +179,29 @@ sub get_unified_diff { my ($attachment, $format) = @_; # Bring in the modules we need. - require PatchReader::Raw; - require PatchReader::FixPatchRoot; - require PatchReader::DiffPrinter::raw; - require PatchReader::PatchInfoGrabber; + require Bugzilla::PatchReader::Raw; + require Bugzilla::PatchReader::FixPatchRoot; + require Bugzilla::PatchReader::DiffPrinter::raw; + require Bugzilla::PatchReader::PatchInfoGrabber; require File::Temp; $attachment->ispatch || ThrowCodeError('must_be_patch', { 'attach_id' => $attachment->id }); # Reads in the patch, converting to unified diff in a temp file. - my $reader = new PatchReader::Raw; + my $reader = new Bugzilla::PatchReader::Raw; my $last_reader = $reader; # Fixes patch root (makes canonical if possible). if (Bugzilla->params->{'cvsroot'}) { my $fix_patch_root = - new PatchReader::FixPatchRoot(Bugzilla->params->{'cvsroot'}); + new Bugzilla::PatchReader::FixPatchRoot(Bugzilla->params->{'cvsroot'}); $last_reader->sends_data_to($fix_patch_root); $last_reader = $fix_patch_root; } # Grabs the patch file info. - my $patch_info_grabber = new PatchReader::PatchInfoGrabber(); + my $patch_info_grabber = new Bugzilla::PatchReader::PatchInfoGrabber(); $last_reader->sends_data_to($patch_info_grabber); $last_reader = $patch_info_grabber; @@ -184,7 +211,7 @@ sub get_unified_diff { # The HTML page will be displayed with the UTF-8 encoding. binmode $fh, ':utf8'; } - my $raw_printer = new PatchReader::DiffPrinter::raw($fh); + my $raw_printer = new Bugzilla::PatchReader::DiffPrinter::raw($fh); $last_reader->sends_data_to($raw_printer); $last_reader = $raw_printer; @@ -228,13 +255,13 @@ sub setup_patch_readers { # Define the patch readers. # The reader that reads the patch in (whatever its format). - require PatchReader::Raw; - my $reader = new PatchReader::Raw; + require Bugzilla::PatchReader::Raw; + my $reader = new Bugzilla::PatchReader::Raw; my $last_reader = $reader; # Fix the patch root if we have a cvs root. if (Bugzilla->params->{'cvsroot'}) { - require PatchReader::FixPatchRoot; - $last_reader->sends_data_to(new PatchReader::FixPatchRoot(Bugzilla->params->{'cvsroot'})); + require Bugzilla::PatchReader::FixPatchRoot; + $last_reader->sends_data_to(new Bugzilla::PatchReader::FixPatchRoot(Bugzilla->params->{'cvsroot'})); $last_reader->sends_data_to->diff_root($diff_root) if defined($diff_root); $last_reader = $last_reader->sends_data_to; } @@ -243,12 +270,12 @@ sub setup_patch_readers { if ($context ne 'patch' && Bugzilla->localconfig->{cvsbin} && Bugzilla->params->{'cvsroot_get'}) { - require PatchReader::AddCVSContext; + require Bugzilla::PatchReader::AddCVSContext; # We need to set $cvsbin as global, because PatchReader::CVSClient # needs it in order to find 'cvs'. $main::cvsbin = Bugzilla->localconfig->{cvsbin}; $last_reader->sends_data_to( - new PatchReader::AddCVSContext($context, Bugzilla->params->{'cvsroot_get'})); + new Bugzilla::PatchReader::AddCVSContext($context, Bugzilla->params->{'cvsroot_get'})); $last_reader = $last_reader->sends_data_to; } @@ -260,7 +287,7 @@ sub setup_template_patch_reader { my $cgi = Bugzilla->cgi; my $template = Bugzilla->template; - require PatchReader::DiffPrinter::template; + require Bugzilla::PatchReader::DiffPrinter::template; # Define the vars for templates. if (defined $cgi->param('headers')) { @@ -276,10 +303,9 @@ sub setup_template_patch_reader { && Bugzilla->params->{'cvsroot_get'} && !$vars->{'newid'}; # Print everything out. - print $cgi->header(-type => 'text/html', - -expires => '+3M'); + print $cgi->header(-type => 'text/html'); - $last_reader->sends_data_to(new PatchReader::DiffPrinter::template($template, + $last_reader->sends_data_to(new Bugzilla::PatchReader::DiffPrinter::template($template, "attachment/diff-header.$format.tmpl", "attachment/diff-file.$format.tmpl", "attachment/diff-footer.$format.tmpl", diff --git a/Bugzilla/Auth.pm b/Bugzilla/Auth.pm index 45034e166..2c58b52a8 100644 --- a/Bugzilla/Auth.pm +++ b/Bugzilla/Auth.pm @@ -38,6 +38,7 @@ use Bugzilla::User::Setting (); use Bugzilla::Auth::Login::Stack; use Bugzilla::Auth::Verify::Stack; use Bugzilla::Auth::Persist::Cookie; +use Socket; sub new { my ($class, $params) = @_; @@ -123,6 +124,15 @@ sub can_logout { return $getter->can_logout; } +sub login_token { + my ($self) = @_; + my $getter = $self->{_info_getter}->{successful}; + if ($getter && $getter->isa('Bugzilla::Auth::Login::Cookie')) { + return $getter->login_token; + } + return undef; +} + sub user_can_create_account { my ($self) = @_; my $verifier = $self->{_verifier}->{successful}; @@ -215,10 +225,19 @@ sub _handle_login_result { my $default_settings = Bugzilla::User::Setting::get_defaults(); my $template = Bugzilla->template_inner( $default_settings->{lang}->{default_value}); + my $address = $attempts->[0]->{ip_addr}; + # Note: inet_aton will only resolve IPv4 addresses. + # For IPv6 we'll need to use inet_pton which requires Perl 5.12. + my $n = inet_aton($address); + if ($n) { + my $host = gethostbyaddr($n, AF_INET); + $address = "$host ($address)" if $host; + } my $vars = { locked_user => $user, attempts => $attempts, unlock_at => $unlock_at, + address => $address, }; my $message; $template->process('email/lockout.txt.tmpl', $vars, \$message) @@ -416,6 +435,14 @@ Params: None Returns: C<true> if users can change their own email address, C<false> otherwise. +=item C<login_token> + +Description: If a login token was used instead of a cookie then this + will return the current login token data such as user id + and the token itself. +Params: None +Returns: A hash containing C<login_token> and C<user_id>. + =back =head1 STRUCTURE diff --git a/Bugzilla/Auth/Login.pm b/Bugzilla/Auth/Login.pm index 42ce51c62..7e03778b3 100644 --- a/Bugzilla/Auth/Login.pm +++ b/Bugzilla/Auth/Login.pm @@ -17,7 +17,7 @@ package Bugzilla::Auth::Login; use strict; -use fields qw(); +use fields qw(_login_token); # Determines whether or not a user can logout. It's really a subroutine, # but we implement it here as a constant. Override it in subclasses if diff --git a/Bugzilla/Auth/Login/Cookie.pm b/Bugzilla/Auth/Login/Cookie.pm index de9188c64..62a6c58a9 100644 --- a/Bugzilla/Auth/Login/Cookie.pm +++ b/Bugzilla/Auth/Login/Cookie.pm @@ -21,13 +21,15 @@ use base qw(Bugzilla::Auth::Login); use Bugzilla::Constants; use Bugzilla::Util; +use Bugzilla::Error; use List::Util qw(first); use constant requires_persistence => 0; use constant requires_verification => 0; use constant can_login => 0; -use constant is_automatic => 1; + +sub is_automatic { return $_[0]->login_token ? 0 : 1; } # Note that Cookie never consults the Verifier, it always assumes # it has a valid DB account or it fails. @@ -35,24 +37,35 @@ sub get_login_info { my ($self) = @_; my $cgi = Bugzilla->cgi; my $dbh = Bugzilla->dbh; + my ($user_id, $login_cookie); - my $ip_addr = remote_ip(); - my $login_cookie = $cgi->cookie("Bugzilla_logincookie"); - my $user_id = $cgi->cookie("Bugzilla_login"); + if (!Bugzilla->request_cache->{auth_no_automatic_login}) { + $login_cookie = $cgi->cookie("Bugzilla_logincookie"); + $user_id = $cgi->cookie("Bugzilla_login"); - # If cookies cannot be found, this could mean that they haven't - # been made available yet. In this case, look at Bugzilla_cookie_list. - unless ($login_cookie) { - my $cookie = first {$_->name eq 'Bugzilla_logincookie'} - @{$cgi->{'Bugzilla_cookie_list'}}; - $login_cookie = $cookie->value if $cookie; + # If cookies cannot be found, this could mean that they haven't + # been made available yet. In this case, look at Bugzilla_cookie_list. + unless ($login_cookie) { + my $cookie = first {$_->name eq 'Bugzilla_logincookie'} + @{$cgi->{'Bugzilla_cookie_list'}}; + $login_cookie = $cookie->value if $cookie; + } + unless ($user_id) { + my $cookie = first {$_->name eq 'Bugzilla_login'} + @{$cgi->{'Bugzilla_cookie_list'}}; + $user_id = $cookie->value if $cookie; + } } - unless ($user_id) { - my $cookie = first {$_->name eq 'Bugzilla_login'} - @{$cgi->{'Bugzilla_cookie_list'}}; - $user_id = $cookie->value if $cookie; + + # If no cookies were provided, we also look for a login token + # passed in the parameters of a webservice + my $token = $self->login_token; + if ($token && (!$login_cookie || !$user_id)) { + ($user_id, $login_cookie) = ($token->{'user_id'}, $token->{'login_token'}); } + my $ip_addr = remote_ip(); + if ($login_cookie && $user_id) { # Anything goes for these params - they're just strings which # we're going to verify against the db @@ -76,13 +89,43 @@ sub get_login_info { WHERE cookie = ?", undef, $login_cookie); return { user_id => $user_id }; } + elsif (i_am_webservice()) { + ThrowUserError('invalid_cookies_or_token'); + } } - # Either the he cookie is invalid, or we got no cookie. We don't want - # to ever return AUTH_LOGINFAILED, because we don't want Bugzilla to - # actually throw an error when it gets a bad cookie. It should just - # look like there was no cookie to begin with. + # Either the cookie or token is invalid and we are not authenticating + # via a webservice, or we did not receive a cookie or token. We don't + # want to ever return AUTH_LOGINFAILED, because we don't want Bugzilla to + # actually throw an error when it gets a bad cookie or token. It should just + # look like there was no cookie or token to begin with. return { failure => AUTH_NODATA }; } +sub login_token { + my ($self) = @_; + my $input = Bugzilla->input_params; + my $usage_mode = Bugzilla->usage_mode; + + return $self->{'_login_token'} if exists $self->{'_login_token'}; + + if (!i_am_webservice()) { + return $self->{'_login_token'} = undef; + } + + # Check if a token was passed in via requests for WebServices + my $token = trim(delete $input->{'Bugzilla_token'}); + return $self->{'_login_token'} = undef if !$token; + + my ($user_id, $login_token) = split('-', $token, 2); + if (!detaint_natural($user_id) || !$login_token) { + return $self->{'_login_token'} = undef; + } + + return $self->{'_login_token'} = { + user_id => $user_id, + login_token => $login_token + }; +} + 1; diff --git a/Bugzilla/Auth/Persist/Cookie.pm b/Bugzilla/Auth/Persist/Cookie.pm index ace474635..c1d133772 100644 --- a/Bugzilla/Auth/Persist/Cookie.pm +++ b/Bugzilla/Auth/Persist/Cookie.pm @@ -36,6 +36,8 @@ use Bugzilla::Constants; use Bugzilla::Util; use Bugzilla::Token; +use Bugzilla::Auth::Login::Cookie qw(login_token); + use List::Util qw(first); sub new { @@ -107,6 +109,7 @@ sub logout { my $dbh = Bugzilla->dbh; my $cgi = Bugzilla->cgi; + my $input = Bugzilla->input_params; $param = {} unless $param; my $user = $param->{user} || Bugzilla->user; my $type = $param->{type} || LOGOUT_ALL; @@ -120,16 +123,24 @@ sub logout { # The LOGOUT_*_CURRENT options require the current login cookie. # If a new cookie has been issued during this run, that's the current one. # If not, it's the one we've received. + my @login_cookies; my $cookie = first {$_->name eq 'Bugzilla_logincookie'} @{$cgi->{'Bugzilla_cookie_list'}}; - my $login_cookie; if ($cookie) { - $login_cookie = $cookie->value; + push(@login_cookies, $cookie->value); } else { - $login_cookie = $cgi->cookie("Bugzilla_logincookie") || ''; + push(@login_cookies, $cgi->cookie("Bugzilla_logincookie")); + } + + # If we are a webservice using a token instead of cookie + # then add that as well to the login cookies to delete + if (my $login_token = $user->authorizer->login_token) { + push(@login_cookies, $login_token->{'login_token'}); } - trick_taint($login_cookie); + + # Make sure that @login_cookies is not empty to not break SQL statements. + push(@login_cookies, '') unless @login_cookies; # These queries use both the cookie ID and the user ID as keys. Even # though we know the userid must match, we still check it in the SQL @@ -138,12 +149,18 @@ sub logout { # logged in and got the same cookie, we could be logging the other # user out here. Yes, this is very very very unlikely, but why take # chances? - bbaetz + map { trick_taint($_) } @login_cookies; + @login_cookies = map { $dbh->quote($_) } @login_cookies; if ($type == LOGOUT_KEEP_CURRENT) { - $dbh->do("DELETE FROM logincookies WHERE cookie != ? AND userid = ?", - undef, $login_cookie, $user->id); + $dbh->do("DELETE FROM logincookies WHERE " . + $dbh->sql_in('cookie', \@login_cookies, 1) . + " AND userid = ?", + undef, $user->id); } elsif ($type == LOGOUT_CURRENT) { - $dbh->do("DELETE FROM logincookies WHERE cookie = ? AND userid = ?", - undef, $login_cookie, $user->id); + $dbh->do("DELETE FROM logincookies WHERE " . + $dbh->sql_in('cookie', \@login_cookies) . + " AND userid = ?", + undef, $user->id); } else { die("Invalid type $type supplied to logout()"); } diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm index 7b86ab2a1..51035ce5a 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -80,7 +80,8 @@ use constant AUDIT_UPDATES => 0; # This is a sub because it needs to call other subroutines. sub DB_COLUMNS { my $dbh = Bugzilla->dbh; - my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT} + my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT + && $_->type != FIELD_TYPE_EXTENSION} Bugzilla->active_custom_fields; my @custom_names = map {$_->name} @custom; @@ -93,6 +94,7 @@ sub DB_COLUMNS { bug_status cclist_accessible component_id + creation_ts delta_ts estimated_time everconfirmed @@ -111,12 +113,11 @@ sub DB_COLUMNS { version ), 'reporter AS reporter_id', - $dbh->sql_date_format('creation_ts', '%Y.%m.%d %H:%i') . ' AS creation_ts', $dbh->sql_date_format('deadline', '%Y-%m-%d') . ' AS deadline', @custom_names); - + Bugzilla::Hook::process("bug_columns", { columns => \@columns }); - + return @columns; } @@ -166,6 +167,9 @@ sub VALIDATORS { elsif ($field->type == FIELD_TYPE_DATETIME) { $validator = \&_check_datetime_field; } + elsif ($field->type == FIELD_TYPE_DATE) { + $validator = \&_check_date_field; + } elsif ($field->type == FIELD_TYPE_FREETEXT) { $validator = \&_check_freetext_field; } @@ -211,7 +215,8 @@ sub VALIDATOR_DEPENDENCIES { }; sub UPDATE_COLUMNS { - my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT} + my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT + && $_->type != FIELD_TYPE_EXTENSION} Bugzilla->active_custom_fields; my @custom_names = map {$_->name} @custom; my @columns = qw( @@ -248,7 +253,8 @@ use constant NUMERIC_COLUMNS => qw( ); sub DATE_COLUMNS { - my @fields = @{ Bugzilla->fields({ type => FIELD_TYPE_DATETIME }) }; + my @fields = (@{ Bugzilla->fields({ type => FIELD_TYPE_DATETIME }) }, + @{ Bugzilla->fields({ type => FIELD_TYPE_DATE }) }); return map { $_->name } @fields; } @@ -280,10 +286,6 @@ use constant FIELD_MAP => { summary => 'short_desc', url => 'bug_file_loc', whiteboard => 'status_whiteboard', - - # These are special values for the WebService Bug.search method. - limit => 'LIMIT', - offset => 'OFFSET', }; use constant REQUIRED_FIELD_MAP => { @@ -311,21 +313,11 @@ use constant EXTRA_REQUIRED_FIELDS => qw(creation_ts target_milestone cc qa_cont ##################################################################### -# This and "new" catch every single way of creating a bug, so that we -# can call _create_cf_accessors. -sub _do_list_select { - my $invocant = shift; - $invocant->_create_cf_accessors(); - return $invocant->SUPER::_do_list_select(@_); -} - sub new { my $invocant = shift; my $class = ref($invocant) || $invocant; my $param = shift; - $class->_create_cf_accessors(); - # Remove leading "#" mark if we've just been passed an id. if (!ref $param && $param =~ /^#(\d+)$/) { $param = $1; @@ -333,10 +325,14 @@ sub new { # If we get something that looks like a word (not a number), # make it the "name" param. - if (!defined $param || (!ref($param) && $param !~ /^\d+$/)) { + if (!defined $param + || (!ref($param) && (!$param || $param =~ /\D/)) + || (ref($param) && (!$param->{id} || $param->{id} =~ /\D/))) + { # But only if aliases are enabled. if (Bugzilla->params->{'usebugaliases'} && $param) { - $param = { name => $param }; + $param = { name => ref($param) ? $param->{id} : $param, + cache => ref($param) ? $param->{cache} : 0 }; } else { # Aliases are off, and we got something that's not a number. @@ -370,20 +366,35 @@ sub new { return $self; } -sub check { +sub initialize { + $_[0]->_create_cf_accessors(); +} + +sub cache_key { my $class = shift; - my ($id, $field) = @_; + my $key = $class->SUPER::cache_key(@_) + || return; + return $key . ',' . Bugzilla->user->id; +} - ThrowUserError('improper_bug_id_field_value', { field => $field }) unless defined $id; +sub check { + my $class = shift; + my ($param, $field) = @_; # Bugzilla::Bug throws lots of special errors, so we don't call # SUPER::check, we just call our new and do our own checks. - my $self = $class->new(trim($id)); - # For error messages, use the id that was returned by new(), because - # it's cleaned up. - $id = $self->id; + my $id = ref($param) + ? ($param->{id} = trim($param->{id})) + : ($param = trim($param)); + ThrowUserError('improper_bug_id_field_value', { field => $field }) unless defined $id; + + my $self = $class->new($param); if ($self->{error}) { + # For error messages, use the id that was returned by new(), because + # it's cleaned up. + $id = $self->id; + if ($self->{error} eq 'NotFound') { ThrowUserError("bug_id_does_not_exist", { bug_id => $id }); } @@ -493,6 +504,13 @@ sub preload { # If we don't do this, can_see_bug will do one call per bug in # the dependency lists, during get_bug_link in Bugzilla::Template. $user->visible_bugs(\@all_dep_ids); + + # We preload comments here in order to allow us to compare the time it + # takes to load comments from the database with the template rendering + # time. + foreach my $bug (@$bugs) { + $bug->comments(); + } } sub possible_duplicates { @@ -676,21 +694,20 @@ sub create { # Set up dependencies (blocked/dependson) my $sth_deps = $dbh->prepare( 'INSERT INTO dependencies (blocked, dependson) VALUES (?, ?)'); - my $sth_bug_time = $dbh->prepare('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?'); foreach my $depends_on_id (@$depends_on) { $sth_deps->execute($bug->bug_id, $depends_on_id); # Log the reverse action on the other bug. LogActivityEntry($depends_on_id, 'blocked', '', $bug->bug_id, $bug->{reporter_id}, $timestamp); - $sth_bug_time->execute($timestamp, $depends_on_id); + _update_delta_ts($depends_on_id, $timestamp); } foreach my $blocked_id (@$blocked) { $sth_deps->execute($blocked_id, $bug->bug_id); # Log the reverse action on the other bug. LogActivityEntry($blocked_id, 'dependson', '', $bug->bug_id, $bug->{reporter_id}, $timestamp); - $sth_bug_time->execute($timestamp, $blocked_id); + _update_delta_ts($blocked_id, $timestamp); } # Insert the values into the multiselect value tables @@ -721,7 +738,10 @@ sub create { # Because MySQL doesn't support transactions on the fulltext table, # we do this after we've committed the transaction. That way we're # sure we're inserting a good Bug ID. - $bug->_sync_fulltext('new bug'); + $bug->_sync_fulltext( new_bug => 1 ); + + # BMO - some work should happen outside of the transaction block + Bugzilla::Hook::process('bug_after_create', { bug => $bug, timestamp => $timestamp }); return $bug; } @@ -775,8 +795,9 @@ sub run_create_validators { sub update { my $self = shift; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; - my $dbh = Bugzilla->dbh; # XXX This is just a temporary hack until all updating happens # inside this function. my $delta_ts = shift || $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); @@ -785,6 +806,10 @@ sub update { my ($changes, $old_bug) = $self->SUPER::update(@_); + Bugzilla::Hook::process('bug_start_of_update', + { timestamp => $delta_ts, bug => $self, + old_bug => $old_bug, changes => $changes }); + # Certain items in $changes have to be fixed so that they hold # a name instead of an ID. foreach my $field (qw(product_id component_id)) { @@ -863,10 +888,9 @@ sub update { # Add an activity entry for the other bug. LogActivityEntry($removed_id, $other, $self->id, '', - Bugzilla->user->id, $delta_ts); + $user->id, $delta_ts); # Update delta_ts on the other bug so that we trigger mid-airs. - $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?', - undef, $delta_ts, $removed_id); + _update_delta_ts($removed_id, $delta_ts); } foreach my $added_id (@$added) { $dbh->do("INSERT INTO dependencies ($type, $other) VALUES (?,?)", @@ -874,10 +898,9 @@ sub update { # Add an activity entry for the other bug. LogActivityEntry($added_id, $other, '', $self->id, - Bugzilla->user->id, $delta_ts); + $user->id, $delta_ts); # Update delta_ts on the other bug so that we trigger mid-airs. - $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?', - undef, $delta_ts, $added_id); + _update_delta_ts($added_id, $delta_ts); } if (scalar(@$removed) || scalar(@$added)) { @@ -922,7 +945,7 @@ sub update { $comment = Bugzilla::Comment->insert_create_data($comment); if ($comment->work_time) { LogActivityEntry($self->id, "work_time", "", $comment->work_time, - Bugzilla->user->id, $delta_ts); + $user->id, $delta_ts); } } @@ -933,7 +956,7 @@ sub update { my ($from, $to) = $comment->is_private ? (0, 1) : (1, 0); LogActivityEntry($self->id, "longdescs.isprivate", $from, $to, - Bugzilla->user->id, $delta_ts, $comment->id); + $user->id, $delta_ts, $comment->id); } # Insert the values into the multiselect value tables @@ -971,6 +994,10 @@ sub update { $_->update foreach @{ $self->{_update_ref_bugs} || [] }; delete $self->{_update_ref_bugs}; + # BMO - allow extensions to alter what is logged into bugs_activity + Bugzilla::Hook::process('bug_update_before_logging', + { bug => $self, timestamp => $delta_ts, changes => $changes, old_bug => $old_bug }); + # Log bugs_activity items # XXX Eventually, when bugs_activity is able to track the dupe_id, # this code should go below the duplicates-table-updating code below. @@ -978,8 +1005,8 @@ sub update { my $change = $changes->{$field}; my $from = defined $change->[0] ? $change->[0] : ''; my $to = defined $change->[1] ? $change->[1] : ''; - LogActivityEntry($self->id, $field, $from, $to, Bugzilla->user->id, - $delta_ts); + LogActivityEntry($self->id, $field, $from, $to, + $user->id, $delta_ts); } # Check if we have to update the duplicates table and the other bug. @@ -993,7 +1020,7 @@ sub update { $update_dup->update(); } } - + $changes->{'dup_id'} = [$old_dup || undef, $cur_dup || undef]; } @@ -1005,20 +1032,39 @@ sub update { if (scalar(keys %$changes) || $self->{added_comments} || $self->{comment_isprivate}) { - $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?', - undef, ($delta_ts, $self->id)); + _update_delta_ts($self->id, $delta_ts); $self->{delta_ts} = $delta_ts; } + # Update bug ignore data if user wants to ignore mail for this bug + if (exists $self->{'bug_ignored'}) { + my $bug_ignored_changed; + if ($self->{'bug_ignored'} && !$user->is_bug_ignored($self->id)) { + $dbh->do('INSERT INTO email_bug_ignore + (user_id, bug_id) VALUES (?, ?)', + undef, $user->id, $self->id); + $bug_ignored_changed = 1; + + } + elsif (!$self->{'bug_ignored'} && $user->is_bug_ignored($self->id)) { + $dbh->do('DELETE FROM email_bug_ignore + WHERE user_id = ? AND bug_id = ?', + undef, $user->id, $self->id); + $bug_ignored_changed = 1; + } + delete $user->{bugs_ignored} if $bug_ignored_changed; + } + $dbh->bz_commit_transaction(); # The only problem with this here is that update() is often called # in the middle of a transaction, and if that transaction is rolled # back, this change will *not* be rolled back. As we expect rollbacks # to be extremely rare, that is OK for us. - $self->_sync_fulltext() - if $self->{added_comments} || $changes->{short_desc} - || $self->{comment_isprivate}; + $self->_sync_fulltext( + update_short_desc => $changes->{short_desc}, + update_comments => $self->{added_comments} || $self->{comment_isprivate} + ); # Remove obsolete internal variables. delete $self->{'_old_assigned_to'}; @@ -1026,7 +1072,11 @@ sub update { # Also flush the visible_bugs cache for this bug as the user's # relationship with this bug may have changed. - delete Bugzilla->user->{_visible_bugs_cache}->{$self->id}; + delete $user->{_visible_bugs_cache}->{$self->id}; + + # BMO - some work should happen outside of the transaction block + Bugzilla::Hook::process('bug_after_update', + { bug => $self, timestamp => $delta_ts, changes => $changes, old_bug => $old_bug }); return $changes; } @@ -1052,27 +1102,56 @@ sub _extract_multi_selects { # Should be called any time you update short_desc or change a comment. sub _sync_fulltext { - my ($self, $new_bug) = @_; + my ($self, %options) = @_; my $dbh = Bugzilla->dbh; - if ($new_bug) { - $dbh->do('INSERT INTO bugs_fulltext (bug_id, short_desc) - SELECT bug_id, short_desc FROM bugs WHERE bug_id = ?', - undef, $self->id); + + my($all_comments, $public_comments); + if ($options{new_bug} || $options{update_comments}) { + my $comments = $dbh->selectall_arrayref( + 'SELECT thetext, isprivate FROM longdescs WHERE bug_id = ?', + undef, $self->id); + $all_comments = join("\n", map { $_->[0] } @$comments); + my @no_private = grep { !$_->[1] } @$comments; + $public_comments = join("\n", map { $_->[0] } @no_private); } - else { - $dbh->do('UPDATE bugs_fulltext SET short_desc = ? WHERE bug_id = ?', - undef, $self->short_desc, $self->id); + + if ($options{new_bug}) { + $dbh->do('INSERT INTO bugs_fulltext (bug_id, short_desc, comments, + comments_noprivate) + VALUES (?, ?, ?, ?)', + undef, + $self->id, $self->short_desc, $all_comments, $public_comments); + } else { + my(@names, @values); + if ($options{update_short_desc}) { + push @names, 'short_desc'; + push @values, $self->short_desc; + } + if ($options{update_comments}) { + push @names, ('comments', 'comments_noprivate'); + push @values, ($all_comments, $public_comments); + } + if (@names) { + $dbh->do('UPDATE bugs_fulltext SET ' . + join(', ', map { "$_ = ?" } @names) . + ' WHERE bug_id = ?', + undef, + @values, $self->id); + } } - my $comments = $dbh->selectall_arrayref( - 'SELECT thetext, isprivate FROM longdescs WHERE bug_id = ?', - undef, $self->id); - my $all = join("\n", map { $_->[0] } @$comments); - my @no_private = grep { !$_->[1] } @$comments; - my $nopriv_string = join("\n", map { $_->[0] } @no_private); - $dbh->do('UPDATE bugs_fulltext SET comments = ?, comments_noprivate = ? - WHERE bug_id = ?', undef, $all, $nopriv_string, $self->id); } +# Update a bug's delta_ts without requiring the full object to be loaded. +sub _update_delta_ts { + my ($bug_id, $timestamp) = @_; + Bugzilla->dbh->do( + "UPDATE bugs SET delta_ts = ? WHERE bug_id = ?", + undef, + $timestamp, $bug_id + ); + Bugzilla::Hook::process('bug_end_of_update_delta_ts', + { bug_id => $bug_id, timestamp => $timestamp }); +} # This is the correct way to delete bugs from the DB. # No bug should be deleted from anywhere else except from here. @@ -1161,15 +1240,15 @@ sub send_changes { changer => $user, ); - _send_bugmail({ id => $self->id, type => 'bug', forced => \%forced }, - $vars); + my $recipient_count = _send_bugmail( + { id => $self->id, type => 'bug', forced => \%forced }, $vars); # If the bug was marked as a duplicate, we need to notify users on the # other bug of any changes to that bug. my $new_dup_id = $changes->{'dup_id'} ? $changes->{'dup_id'}->[1] : undef; if ($new_dup_id) { - _send_bugmail({ forced => { changer => $user }, type => "dupe", - id => $new_dup_id }, $vars); + $recipient_count += _send_bugmail( + { forced => { changer => $user }, type => "dupe", id => $new_dup_id }, $vars); } # If there were changes in dependencies, we need to notify those @@ -1188,7 +1267,7 @@ sub send_changes { foreach my $id (@{ $self->blocked }) { $params->{id} = $id; - _send_bugmail($params, $vars); + $recipient_count += _send_bugmail($params, $vars); } } } @@ -1206,15 +1285,17 @@ sub send_changes { delete $changed_deps{''}; foreach my $id (sort { $a <=> $b } (keys %changed_deps)) { - _send_bugmail({ forced => { changer => $user }, type => "dep", - id => $id }, $vars); + $recipient_count += _send_bugmail( + { forced => { changer => $user }, type => "dep", id => $id }, $vars); } # Sending emails for the referenced bugs. foreach my $ref_bug_id (uniq @{ $self->{see_also_changes} || [] }) { - _send_bugmail({ forced => { changer => $user }, - id => $ref_bug_id }, $vars); + $recipient_count += _send_bugmail( + { forced => { changer => $user }, id => $ref_bug_id }, $vars); } + + return $recipient_count; } sub _send_bugmail { @@ -1222,7 +1303,7 @@ sub _send_bugmail { require Bugzilla::BugMail; - my $results = + my $results = Bugzilla::BugMail::Send($params->{'id'}, $params->{'forced'}, $params); if (Bugzilla->usage_mode == USAGE_MODE_BROWSER) { @@ -1233,6 +1314,8 @@ sub _send_bugmail { || ThrowTemplateError($template->error()); $vars->{'header_done'} = 1; } + + return scalar @{ $results->{sent} }; } ##################################################################### @@ -1655,7 +1738,9 @@ sub _check_groups { foreach my $name (@$group_names) { my $group = Bugzilla::Group->check_no_disclose({ %args, name => $name }); - if (!$product->group_is_settable($group)) { + # BMO : allow bugs to be always placed into some groups + if (!$product->group_always_settable($group) + && !$product->group_is_settable($group)) { ThrowUserError('group_restriction_not_allowed', { %args, name => $name }); } $add_groups{$group->id} = $group; @@ -1664,7 +1749,7 @@ sub _check_groups { # Now enforce mandatory groups. $add_groups{$_->id} = $_ foreach @{ $product->groups_mandatory }; - + my @add_groups = values %add_groups; return \@add_groups; } @@ -1986,8 +2071,12 @@ sub _check_field_is_mandatory { } } +sub _check_date_field { + my ($invocant, $date) = @_; + return $invocant->_check_datetime_field($date, undef, {date_only => 1}); +} sub _check_datetime_field { - my ($invocant, $date_time) = @_; + my ($invocant, $date_time, $field, $params) = @_; # Empty datetimes are empty strings or strings only containing # 0's, whitespace, and punctuation. @@ -2001,6 +2090,10 @@ sub _check_datetime_field { ThrowUserError('illegal_date', { date => $date, format => 'YYYY-MM-DD' }); } + if ($time && $params->{date_only}) { + ThrowUserError('illegal_date', { date => $date_time, + format => 'YYYY-MM-DD' }); + } if ($time && !validate_time($time)) { ThrowUserError('illegal_time', { 'time' => $time, format => 'HH:MM:SS' }); @@ -2251,7 +2344,8 @@ sub set_all { $self->_add_remove($params, 'see_also'); # And set custom fields. - my @custom_fields = Bugzilla->active_custom_fields; + my @custom_fields = grep { $_->type != FIELD_TYPE_EXTENSION } + Bugzilla->active_custom_fields; foreach my $field (@custom_fields) { my $fname = $field->name; if (exists $params->{$fname}) { @@ -2266,7 +2360,7 @@ sub set_all { # we have to check that the current assignee, qa, and CCs are still # valid if we've switched products, under strict_isolation. We can only # do that here, because if they *did* change the assignee, qa, or CC, - # then we don't want to check the original ones, only the new ones. + # then we don't want to check the original ones, only the new ones. $self->_check_strict_isolation() if $product_changed; } @@ -2296,6 +2390,7 @@ sub reset_assigned_to { my $comp = $self->component_obj; $self->set_assigned_to($comp->default_assignee); } +sub set_bug_ignored { $_[0]->set('bug_ignored', $_[1]); } sub set_cclist_accessible { $_[0]->set('cclist_accessible', $_[1]); } sub set_comment_is_private { my ($self, $comment_id, $isprivate) = @_; @@ -2794,19 +2889,25 @@ sub add_group { return if $self->in_group($group); - # Make sure that bugs in this product can actually be restricted - # to this group by the current user. - $self->product_obj->group_is_settable($group) - || ThrowUserError('group_restriction_not_allowed', $args); + # BMO : allow bugs to be always placed into some groups by the bug's + # reporter + if ($self->{reporter_id} != Bugzilla->user->id + || !$self->product_obj->group_always_settable($group)) + { + # Make sure that bugs in this product can actually be restricted + # to this group by the current user. + $self->product_obj->group_is_settable($group) + || ThrowUserError('group_restriction_not_allowed', $args); - # OtherControl people can add groups only during a product change, - # and only when the group is not NA for them. - if (!Bugzilla->user->in_group($group->name)) { - my $controls = $self->product_obj->group_controls->{$group->id}; - if (!$self->{_old_product_name} - || $controls->{othercontrol} == CONTROLMAPNA) - { - ThrowUserError('group_restriction_not_allowed', $args); + # OtherControl people can add groups only during a product change, + # and only when the group is not NA for them. + if (!Bugzilla->user->in_group($group->name)) { + my $controls = $self->product_obj->group_controls->{$group->id}; + if (!$self->{_old_product_name} + || $controls->{othercontrol} == CONTROLMAPNA) + { + ThrowUserError('group_restriction_not_allowed', $args); + } } } @@ -3161,8 +3262,8 @@ sub assigned_to { my ($self) = @_; return $self->{'assigned_to_obj'} if exists $self->{'assigned_to_obj'}; $self->{'assigned_to'} = 0 if $self->{'error'}; - $self->{'assigned_to_obj'} ||= new Bugzilla::User($self->{'assigned_to'}); - return $self->{'assigned_to_obj'}; + return $self->{'assigned_to_obj'} + = new Bugzilla::User({ id => $self->{'assigned_to'}, cache => 1 }); } sub blocked { @@ -3239,7 +3340,8 @@ sub component_obj { my ($self) = @_; return $self->{component_obj} if defined $self->{component_obj}; return {} if $self->{error}; - $self->{component_obj} = new Bugzilla::Component($self->{component_id}); + $self->{component_obj} = + new Bugzilla::Component({ id => $self->{component_id}, cache => 1 }); return $self->{component_obj}; } @@ -3278,6 +3380,26 @@ sub depends_on_obj { return $self->{depends_on_obj}; } +sub duplicates { + my $self = shift; + return $self->{duplicates} if exists $self->{duplicates}; + return [] if $self->{error}; + $self->{duplicates} = Bugzilla::Bug->new_from_list($self->duplicate_ids); + return $self->{duplicates}; +} + +sub duplicate_ids { + my $self = shift; + return $self->{duplicate_ids} if exists $self->{duplicate_ids}; + return [] if $self->{error}; + + my $dbh = Bugzilla->dbh; + $self->{duplicate_ids} = + $dbh->selectcol_arrayref('SELECT dupe FROM duplicates WHERE dupe_of = ?', + undef, $self->id); + return $self->{duplicate_ids}; +} + sub flag_types { my ($self) = @_; return $self->{'flag_types'} if exists $self->{'flag_types'}; @@ -3286,7 +3408,8 @@ sub flag_types { my $vars = { target_type => 'bug', product_id => $self->{product_id}, component_id => $self->{component_id}, - bug_id => $self->bug_id }; + bug_id => $self->bug_id, + active_or_has_flags => $self->bug_id }; $self->{'flag_types'} = Bugzilla::Flag->_flag_types($vars); return $self->{'flag_types'}; @@ -3335,8 +3458,11 @@ sub comments { $comment->{count} = $count++; $comment->{bug} = $self; } - Bugzilla::Comment->preload($self->{'comments'}); + # Some bugs may have no comments when upgrading old installations. + Bugzilla::Comment->preload($self->{'comments'}) if $count; } + return unless defined wantarray; + my @comments = @{ $self->{'comments'} }; my $order = $params->{order} @@ -3387,7 +3513,8 @@ sub product { sub product_obj { my $self = shift; return {} if $self->{error}; - $self->{product_obj} ||= new Bugzilla::Product($self->{product_id}); + $self->{product_obj} ||= + new Bugzilla::Product({ id => $self->{product_id}, cache => 1 }); return $self->{product_obj}; } @@ -3397,7 +3524,8 @@ sub qa_contact { return undef if $self->{'error'}; if (Bugzilla->params->{'useqacontact'} && $self->{'qa_contact'}) { - $self->{'qa_contact_obj'} = new Bugzilla::User($self->{'qa_contact'}); + $self->{'qa_contact_obj'} + = new Bugzilla::User({ id => $self->{'qa_contact'}, cache => 1 }); } else { # XXX - This is somewhat inconsistent with the assignee/reporter # methods, which will return an empty User if they get a 0. @@ -3411,8 +3539,8 @@ sub reporter { my ($self) = @_; return $self->{'reporter'} if exists $self->{'reporter'}; $self->{'reporter_id'} = 0 if $self->{'error'}; - $self->{'reporter'} = new Bugzilla::User($self->{'reporter_id'}); - return $self->{'reporter'}; + return $self->{'reporter'} + = new Bugzilla::User({ id => $self->{'reporter_id'}, cache => 1 }); } sub see_also { @@ -3553,6 +3681,49 @@ sub groups { } } + # BMO: if required, hack in groups exposed by -visible membership + # (eg mozilla-corporation-confidential-visible), so reporters can add the + # bug to a group on show_bug. + # if the bug is already in the group, the user will not be able to remove + # it unless they are a true group member. + my $user = Bugzilla->user; + if ($self->{'reporter_id'} == $user->id) { + foreach my $group (@{ $user->groups }) { + # map from -visible group to the real one + my $group_name = $group->name; + next unless $group_name =~ s/-visible$//; + next if $user->in_group($group_name); + $group = Bugzilla::Group->new({ name => $group_name, cache => 1 }); + + # only show the group if it's visible to normal members + my ($member_control) = $dbh->selectrow_array( + "SELECT membercontrol + FROM groups + LEFT JOIN group_control_map + ON group_control_map.group_id = groups.id + AND group_control_map.product_id = ? + WHERE groups.id = ?", + undef, + $self->{product_id}, $group->id + ); + + if ( + $member_control + && $member_control == CONTROLMAPSHOWN + && !grep { $_->{bit} == $group->id } @groups) + { + push(@groups, { + bit => $group->id, + name => $group->name, + ison => 0, + ingroup => 1, + mandatory => 0, + description => $group->description, + }); + } + } + } + $self->{'groups'} = \@groups; return $self->{'groups'}; @@ -3671,6 +3842,9 @@ sub editable_bug_fields { # Ensure field exists before attempting to remove it. splice(@fields, $location, 1) if ($location > -1); } + + Bugzilla::Hook::process('bug_editable_bug_fields', { fields => \@fields }); + # Sorted because the old @::log_columns variable, which this replaces, # was sorted. return sort(@fields); @@ -3706,7 +3880,7 @@ sub _bugs_in_order { # Get the activity of a bug, starting from $starttime (if given). # This routine assumes Bugzilla::Bug->check has been previously called. sub GetBugActivity { - my ($bug_id, $attach_id, $starttime) = @_; + my ($bug_id, $attach_id, $starttime, $include_comment_tags) = @_; my $dbh = Bugzilla->dbh; # Arguments passed to the SQL query. @@ -3717,7 +3891,7 @@ sub GetBugActivity { if (defined $starttime) { trick_taint($starttime); push (@args, $starttime); - $datepart = "AND bugs_activity.bug_when > ?"; + $datepart = "AND bug_when > ?"; } my $attachpart = ""; @@ -3738,7 +3912,7 @@ sub GetBugActivity { my $query = "SELECT fielddefs.name, bugs_activity.attach_id, " . $dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s') . - ", bugs_activity.removed, bugs_activity.added, profiles.login_name, + " AS bug_when, bugs_activity.removed, bugs_activity.added, profiles.login_name, bugs_activity.comment_id FROM bugs_activity $suppjoins @@ -3749,8 +3923,31 @@ sub GetBugActivity { WHERE bugs_activity.bug_id = ? $datepart $attachpart - $suppwhere - ORDER BY bugs_activity.bug_when"; + $suppwhere "; + + if (Bugzilla->params->{'comment_taggers_group'} + && $include_comment_tags + && !$attach_id) + { + $query .= " + UNION ALL + SELECT 'comment_tag' AS name, + NULL AS attach_id," . + $dbh->sql_date_format('longdescs_tags_activity.bug_when', '%Y.%m.%d %H:%i:%s') . " AS bug_when, + longdescs_tags_activity.removed, + longdescs_tags_activity.added, + profiles.login_name, + longdescs_tags_activity.comment_id as comment_id + FROM longdescs_tags_activity + INNER JOIN profiles ON profiles.userid = longdescs_tags_activity.who + WHERE longdescs_tags_activity.bug_id = ? + $datepart + "; + push @args, $bug_id; + push @args, $starttime if defined $starttime; + } + + $query .= "ORDER BY bug_when, comment_id"; my $list = $dbh->selectall_arrayref($query, undef, @args); @@ -3805,16 +4002,29 @@ sub GetBugActivity { $changes = []; } + # If this is the same field as the previoius item, then concatenate + # the data into the same change. + if ($operation->{'who'} && $who eq $operation->{'who'} + && $when eq $operation->{'when'} + && $fieldname eq $operation->{'fieldname'} + && ($comment_id || 0) == ($operation->{'comment_id'} || 0) + && ($attachid || 0) == ($operation->{'attachid'} || 0)) + { + my $old_change = pop @$changes; + $removed = _join_activity_entries($fieldname, $old_change->{'removed'}, $removed); + $added = _join_activity_entries($fieldname, $old_change->{'added'}, $added); + } + $operation->{'who'} = $who; $operation->{'when'} = $when; + $operation->{'fieldname'} = $change{'fieldname'} = $fieldname; + $operation->{'attachid'} = $change{'attachid'} = $attachid; - $change{'fieldname'} = $fieldname; - $change{'attachid'} = $attachid; $change{'removed'} = $removed; $change{'added'} = $added; - + if ($comment_id) { - $change{'comment'} = Bugzilla::Comment->new($comment_id); + $operation->{comment_id} = $change{'comment'} = Bugzilla::Comment->new($comment_id); } push (@$changes, \%change); @@ -3829,6 +4039,35 @@ sub GetBugActivity { return(\@operations, $incomplete_data); } +sub _join_activity_entries { + my ($field, $current_change, $new_change) = @_; + # We need to insert characters as these were removed by old + # LogActivityEntry code. + + return $new_change if $current_change eq ''; + + # Buglists and see_also need the comma restored + if ($field eq 'dependson' || $field eq 'blocked' || $field eq 'see_also') { + if (substr($new_change, 0, 1) eq ',' || substr($new_change, 0, 1) eq ' ') { + return $current_change . $new_change; + } else { + return $current_change . ', ' . $new_change; + } + } + + # Assume bug_file_loc contain a single url, don't insert a delimiter + if ($field eq 'bug_file_loc') { + return $current_change . $new_change; + } + + # All other fields get a space + if (substr($new_change, 0, 1) eq ' ') { + return $current_change . $new_change; + } else { + return $current_change . ' ' . $new_change; + } +} + # Update the bugs_activity table to reflect changes made in bugs. sub LogActivityEntry { my ($i, $col, $removed, $added, $whoid, $timestamp, $comment_id) = @_; @@ -3843,7 +4082,6 @@ sub LogActivityEntry { my $commaposition = find_wrap_point($removed, MAX_LINE_LENGTH); $removestr = substr($removed, 0, $commaposition); $removed = substr($removed, $commaposition); - $removed =~ s/^[,\s]+//; # remove any comma or space } else { $removed = ""; # no more entries } @@ -3851,7 +4089,6 @@ sub LogActivityEntry { my $commaposition = find_wrap_point($added, MAX_LINE_LENGTH); $addstr = substr($added, 0, $commaposition); $added = substr($added, $commaposition); - $added =~ s/^[,\s]+//; # remove any comma or space } else { $added = ""; # no more entries } @@ -3938,8 +4175,8 @@ sub check_can_change_field { return 1; } - # Allow anyone to change comments. - if ($field =~ /^longdesc/) { + # Allow anyone to change comments, or set flags + if ($field =~ /^longdesc/ || $field eq 'flagtypes.name') { return 1; } @@ -4145,6 +4382,7 @@ sub _create_cf_accessors { my $fields = Bugzilla->fields({ custom => 1 }); foreach my $field (@$fields) { + next if $field->type == FIELD_TYPE_EXTENSION; my $accessor = $class->_accessor_for($field); my $name = "${class}::" . $field->name; { @@ -4154,6 +4392,8 @@ sub _create_cf_accessors { } } + Bugzilla::Hook::process('bug_create_cf_accessors'); + Bugzilla->request_cache->{"${class}_cf_accessors_created"} = 1; } diff --git a/Bugzilla/BugMail.pm b/Bugzilla/BugMail.pm index 55eeeab25..50d13439c 100644 --- a/Bugzilla/BugMail.pm +++ b/Bugzilla/BugMail.pm @@ -47,7 +47,9 @@ use Bugzilla::Hook; use Date::Parse; use Date::Format; use Scalar::Util qw(blessed); -use List::MoreUtils qw(uniq); +use List::MoreUtils qw(uniq firstidx); +use Sys::Hostname; +use Storable qw(dclone); use constant BIT_DIRECT => 1; use constant BIT_WATCHING => 2; @@ -107,30 +109,53 @@ sub Send { my %user_cache = map { $_->id => $_ } (@assignees, @qa_contacts, @ccs); my @diffs; + my @referenced_bugs; if (!$start) { @diffs = _get_new_bugmail_fields($bug); } if ($params->{dep_only}) { + my $fields = Bugzilla->fields({ by_name => 1 }); push(@diffs, { field_name => 'bug_status', + field_desc => $fields->{bug_status}->description, old => $params->{changes}->{bug_status}->[0], new => $params->{changes}->{bug_status}->[1], login_name => $changer->login, blocker => $params->{blocker} }, { field_name => 'resolution', + field_desc => $fields->{resolution}->description, old => $params->{changes}->{resolution}->[0], new => $params->{changes}->{resolution}->[1], login_name => $changer->login, blocker => $params->{blocker} }); + push(@referenced_bugs, $params->{blocker}->id); } else { - push(@diffs, _get_diffs($bug, $end, \%user_cache)); + my ($diffs, $referenced) = _get_diffs($bug, $end, \%user_cache); + push(@diffs, @$diffs); + push(@referenced_bugs, @$referenced); } my $comments = $bug->comments({ after => $start, to => $end }); # Skip empty comments. @$comments = grep { $_->type || $_->body =~ /\S/ } @$comments; + # Add duplicate bug to referenced bug list + foreach my $comment (@$comments) { + if ($comment->type == CMT_DUPE_OF || $comment->type == CMT_HAS_DUPE) { + push(@referenced_bugs, $comment->extra_data); + } + } + + # Add dependencies to referenced bug list on new bugs + if (!$start) { + push @referenced_bugs, @{ $bug->dependson }; + push @referenced_bugs, @{ $bug->blocked }; + } + + # If no changes have been made, there is no need to process further. + return {'sent' => []} unless scalar(@diffs) || scalar(@$comments); + ########################################################################### # Start of email filtering code ########################################################################### @@ -186,28 +211,30 @@ sub Send { # Make sure %user_cache has every user in it so far referenced foreach my $user_id (keys %recipients) { - $user_cache{$user_id} ||= new Bugzilla::User($user_id); + $user_cache{$user_id} ||= new Bugzilla::User({ id => $user_id, cache => 1 }); } Bugzilla::Hook::process('bugmail_recipients', { bug => $bug, recipients => \%recipients, users => \%user_cache, diffs => \@diffs }); - # Find all those user-watching anyone on the current list, who is not - # on it already themselves. - my $involved = join(",", keys %recipients); + if (scalar keys %recipients) { + # Find all those user-watching anyone on the current list, who is not + # on it already themselves. + my $involved = join(",", keys %recipients); - my $userwatchers = - $dbh->selectall_arrayref("SELECT watcher, watched FROM watch - WHERE watched IN ($involved)"); + my $userwatchers = + $dbh->selectall_arrayref("SELECT watcher, watched FROM watch + WHERE watched IN ($involved)"); - # Mark these people as having the role of the person they are watching - foreach my $watch (@$userwatchers) { - while (my ($role, $bits) = each %{$recipients{$watch->[1]}}) { - $recipients{$watch->[0]}->{$role} |= BIT_WATCHING - if $bits & BIT_DIRECT; + # Mark these people as having the role of the person they are watching + foreach my $watch (@$userwatchers) { + while (my ($role, $bits) = each %{$recipients{$watch->[1]}}) { + $recipients{$watch->[0]}->{$role} |= BIT_WATCHING + if $bits & BIT_DIRECT; + } + push(@{$watching{$watch->[0]}}, $watch->[1]); } - push(@{$watching{$watch->[0]}}, $watch->[1]); } # Global watcher @@ -222,21 +249,25 @@ sub Send { # the bug in question. However, we are not necessarily going to mail them # all - there are preferences, permissions checks and all sorts to do yet. my @sent; - my @excluded; # The email client will display the Date: header in the desired timezone, # so we can always use UTC here. my $date = $params->{dep_only} ? $end : $bug->delta_ts; $date = format_time($date, '%a, %d %b %Y %T %z', 'UTC'); + # Remove duplicate references, and convert to bug objects + @referenced_bugs = @{ Bugzilla::Bug->new_from_list([uniq @referenced_bugs]) }; + foreach my $user_id (keys %recipients) { my %rels_which_want; - my $sent_mail = 0; - $user_cache{$user_id} ||= new Bugzilla::User($user_id); - my $user = $user_cache{$user_id}; + my $user = $user_cache{$user_id} ||= new Bugzilla::User({ id => $user_id, cache => 1 }); # Deleted users must be excluded. next unless $user; + # If email notifications are disabled for this account, or the bug + # is ignored, there is no need to do additional checks. + next if ($user->email_disabled || $user->is_bug_ignored($id)); + if ($user->can_see_bug($id)) { # Go through each role the user has and see if they want mail in # that role. @@ -253,7 +284,7 @@ sub Send { } } } - + if (scalar(%rels_which_want)) { # So the user exists, can see the bug, and wants mail in at least # one role. But do we want to send it to them? @@ -267,10 +298,32 @@ sub Send { } # Make sure the user isn't in the nomail list, and the dep check passed. - if ($user->email_enabled && $dep_ok) { - # OK, OK, if we must. Email the user. - $sent_mail = sendMail( - { to => $user, + # BMO: never send emails to bugs or .tld addresses. this check needs to + # happen after the bugmail_recipients hook. + if ($user->email_enabled && $dep_ok && + ($user->login !~ /bugs$/) && ($user->login !~ /\.tld$/)) + { + # Don't show summaries for bugs the user can't access, and + # provide a hook for extensions such as SecureMail to filter + # this list. + # + # We build an array with the short_desc as a separate item to + # allow extensions to modify the summary without touching the + # bug object. + my $referenced_bugs = []; + foreach my $ref (@{ $user->visible_bugs(\@referenced_bugs) }) { + push @$referenced_bugs, { + bug => $ref, + id => $ref->id, + short_desc => $ref->short_desc, + }; + } + Bugzilla::Hook::process('bugmail_referenced_bugs', + { updated_bug => $bug, + referenced_bugs => $referenced_bugs }); + + my $sent_mail = sendMail( + { to => $user, bug => $bug, comments => $comments, date => $date, @@ -279,16 +332,12 @@ sub Send { $watching{$user_id} : undef, diffs => \@diffs, rels_which_want => \%rels_which_want, + referenced_bugs => $referenced_bugs, + dep_only => $params->{dep_only} }); + push(@sent, $user->login) if $sent_mail; } } - - if ($sent_mail) { - push(@sent, $user->login); - } - else { - push(@excluded, $user->login); - } } # When sending bugmail about a blocker being reopened or resolved, @@ -300,12 +349,12 @@ sub Send { $bug->{lastdiffed} = $end; } - return {'sent' => \@sent, 'excluded' => \@excluded}; + return {'sent' => \@sent}; } sub sendMail { my $params = shift; - + my $user = $params->{to}; my $bug = $params->{bug}; my @send_comments = @{ $params->{comments} }; @@ -314,6 +363,8 @@ sub sendMail { my $watchingRef = $params->{watchers}; my @diffs = @{ $params->{diffs} }; my $relRef = $params->{rels_which_want}; + my $referenced_bugs = $params->{referenced_bugs}; + my $dep_only = $params->{dep_only}; # Only display changes the user is allowed see. my @display_diffs; @@ -352,33 +403,107 @@ sub sendMail { push(@watchingrel, 'None') unless @watchingrel; push @watchingrel, map { user_id_to_login($_) } @$watchingRef; + # BMO: Use field descriptions instead of field names in header + my @changedfields = uniq map { $_->{field_desc} } @display_diffs; + my @changedfieldnames = uniq map { $_->{field_name} } @display_diffs; + + # Add attachments.created to changedfields if one or more + # comments contain information about a new attachment + if (grep($_->type == CMT_ATTACHMENT_CREATED, @send_comments)) { + push(@changedfields, 'Attachment Created'); + push(@changedfieldnames, 'attachment.created'); + } + + my $bugmailtype = "changed"; + $bugmailtype = "new" if !$bug->lastdiffed; + $bugmailtype = "dep_changed" if $dep_only; + my $vars = { - date => $date, - to_user => $user, - bug => $bug, - reasons => \@reasons, - reasons_watch => \@reasons_watch, - reasonsheader => join(" ", @headerrel), + date => $date, + to_user => $user, + bug => $bug, + reasons => \@reasons, + reasons_watch => \@reasons_watch, + reasonsheader => join(" ", @headerrel), reasonswatchheader => join(" ", @watchingrel), - changer => $changer, - diffs => \@display_diffs, - changedfields => [uniq map { $_->{field_name} } @display_diffs], - new_comments => \@send_comments, - threadingmarker => build_thread_marker($bug->id, $user->id, !$bug->lastdiffed), + changer => $changer, + diffs => \@display_diffs, + changedfields => \@changedfields, + changedfieldnames => \@changedfieldnames, + new_comments => \@send_comments, + threadingmarker => build_thread_marker($bug->id, $user->id, !$bug->lastdiffed), + referenced_bugs => $referenced_bugs, + bugmailtype => $bugmailtype, }; - my $msg = _generate_bugmail($user, $vars); - MessageToMTA($msg); + + if (Bugzilla->params->{'use_mailer_queue'}) { + enqueue($vars); + } else { + MessageToMTA(_generate_bugmail($vars)); + } return 1; } +sub enqueue { + my ($vars) = @_; + # we need to flatten all objects to a hash before pushing to the job queue. + # the hashes need to be inflated in the dequeue method. + $vars->{bug} = _flatten_object($vars->{bug}); + $vars->{to_user} = _flatten_object($vars->{to_user}); + $vars->{changer} = _flatten_object($vars->{changer}); + $vars->{new_comments} = [ map { _flatten_object($_) } @{ $vars->{new_comments} } ]; + foreach my $diff (@{ $vars->{diffs} }) { + $diff->{who} = _flatten_object($diff->{who}); + if (exists $diff->{blocker}) { + $diff->{blocker} = _flatten_object($diff->{blocker}); + } + } + foreach my $reference (@{ $vars->{referenced_bugs} }) { + $reference->{bug} = _flatten_object($reference->{bug}); + } + Bugzilla->job_queue->insert('bug_mail', { vars => $vars }); +} + +sub dequeue { + my ($payload) = @_; + # clone the payload so we can modify it without impacting TheSchwartz's + # ability to process the job when we've finished + my $vars = dclone($payload); + # inflate objects + $vars->{bug} = Bugzilla::Bug->new_from_hash($vars->{bug}); + $vars->{to_user} = Bugzilla::User->new_from_hash($vars->{to_user}); + $vars->{changer} = Bugzilla::User->new_from_hash($vars->{changer}); + $vars->{new_comments} = [ map { Bugzilla::Comment->new_from_hash($_) } @{ $vars->{new_comments} } ]; + foreach my $diff (@{ $vars->{diffs} }) { + $diff->{who} = Bugzilla::User->new_from_hash($diff->{who}); + if (exists $diff->{blocker}) { + $diff->{blocker} = Bugzilla::Bug->new_from_hash($diff->{blocker}); + } + } + # generate bugmail and send + MessageToMTA(_generate_bugmail($vars), 1); +} + +sub _flatten_object { + my ($object) = @_; + # nothing to do if it's already flattened + return $object unless blessed($object); + # the same objects are used for each recipient, so cache the flattened hash + my $cache = Bugzilla->request_cache->{bugmail_flat_objects} ||= {}; + my $key = blessed($object) . '-' . $object->id; + return $cache->{$key} ||= $object->flatten_to_hash; +} + sub _generate_bugmail { - my ($user, $vars) = @_; + my ($vars) = @_; + my $user = $vars->{to_user}; my $template = Bugzilla->template_inner($user->setting('lang')); my ($msg_text, $msg_html, $msg_header); - + $template->process("email/bugmail-header.txt.tmpl", $vars, \$msg_header) || ThrowTemplateError($template->error()); + $template->process("email/bugmail.txt.tmpl", $vars, \$msg_text) || ThrowTemplateError($template->error()); @@ -395,7 +520,7 @@ sub _generate_bugmail { || ThrowTemplateError($template->error()); push @parts, Email::MIME->create( attributes => { - content_type => "text/html", + content_type => "text/html", }, body => $msg_html, ); @@ -403,6 +528,10 @@ sub _generate_bugmail { # TT trims the trailing newline, and threadingmarker may be ignored. my $email = new Email::MIME("$msg_header\n"); + + # For tracking/diagnostic purposes, add our hostname + $email->header_set('X-Generated-By' => hostname()); + if (scalar(@parts) == 1) { $email->content_type_set($parts[0]->content_type); } else { @@ -426,6 +555,7 @@ sub _get_diffs { my $diffs = $dbh->selectall_arrayref( "SELECT fielddefs.name AS field_name, + fielddefs.description AS field_desc, bugs_activity.bug_when, bugs_activity.removed AS old, bugs_activity.added AS new, bugs_activity.attach_id, bugs_activity.comment_id, bugs_activity.who @@ -434,11 +564,12 @@ sub _get_diffs { ON fielddefs.id = bugs_activity.fieldid WHERE bugs_activity.bug_id = ? $when_restriction - ORDER BY bugs_activity.bug_when", {Slice=>{}}, @args); + ORDER BY bugs_activity.bug_when, fielddefs.description", {Slice=>{}}, @args); + my $referenced_bugs = []; foreach my $diff (@$diffs) { - $user_cache->{$diff->{who}} ||= new Bugzilla::User($diff->{who}); - $diff->{who} = $user_cache->{$diff->{who}}; + $user_cache->{$diff->{who}} ||= new Bugzilla::User({ id => $diff->{who}, cache => 1 }); + $diff->{who} = $user_cache->{$diff->{who}}; if ($diff->{attach_id}) { $diff->{isprivate} = $dbh->selectrow_array( 'SELECT isprivate FROM attachments WHERE attach_id = ?', @@ -449,9 +580,13 @@ sub _get_diffs { $diff->{num} = $comment->count; $diff->{isprivate} = $diff->{new}; } + elsif ($diff->{field_name} eq 'dependson' || $diff->{field_name} eq 'blocked') { + push @$referenced_bugs, grep { /^\d+$/ } split(/[\s,]+/, $diff->{old}); + push @$referenced_bugs, grep { /^\d+$/ } split(/[\s,]+/, $diff->{new}); + } } - return @$diffs; + return ($diffs, $referenced_bugs); } sub _get_new_bugmail_fields { @@ -459,6 +594,20 @@ sub _get_new_bugmail_fields { my @fields = @{ Bugzilla->fields({obsolete => 0, in_new_bugmail => 1}) }; my @diffs; + # Show fields in the same order as the DEFAULT_FIELDS list, which mirrors + # 4.0's behavour and provides sane grouping of similar fields. + # Any additional fields are sorted by descrsiption + my @prepend; + foreach my $name (map { $_->{name} } Bugzilla::Field::DEFAULT_FIELDS) { + my $idx = firstidx { $_->name eq $name } @fields; + if ($idx != -1) { + push(@prepend, $fields[$idx]); + splice(@fields, $idx, 1); + } + } + @fields = sort { $a->description cmp $b->description } @fields; + @fields = (@prepend, @fields); + foreach my $field (@fields) { my $name = $field->name; my $value = $bug->$name; @@ -484,7 +633,9 @@ sub _get_new_bugmail_fields { # If there isn't anything to show, don't include this header. next unless $value; - push(@diffs, {field_name => $name, new => $value}); + push(@diffs, {field_name => $name, + field_desc => $field->description, + new => $value}); } return @diffs; diff --git a/Bugzilla/BugUrl.pm b/Bugzilla/BugUrl.pm index 837c0d4fe..784600984 100644 --- a/Bugzilla/BugUrl.pm +++ b/Bugzilla/BugUrl.pm @@ -69,6 +69,7 @@ use constant SUB_CLASSES => qw( Bugzilla::BugUrl::Trac Bugzilla::BugUrl::MantisBT Bugzilla::BugUrl::SourceForge + Bugzilla::BugUrl::GitHub ); ############################### diff --git a/Bugzilla/BugUrl/GitHub.pm b/Bugzilla/BugUrl/GitHub.pm new file mode 100644 index 000000000..63be65bed --- /dev/null +++ b/Bugzilla/BugUrl/GitHub.pm @@ -0,0 +1,36 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::BugUrl::GitHub; +use strict; +use base qw(Bugzilla::BugUrl); + +############################### +#### Methods #### +############################### + +sub should_handle { + my ($class, $uri) = @_; + + # GitHub issue URLs have only one form: + # https://github.com/USER_OR_TEAM_OR_ORGANIZATION_NAME/REPOSITORY_NAME/issues/111 + return ($uri->authority =~ /^github.com$/i + and $uri->path =~ m|^/[^/]+/[^/]+/issues/\d+$|) ? 1 : 0; +} + +sub _check_value { + my ($class, $uri) = @_; + + $uri = $class->SUPER::_check_value($uri); + + # GitHub HTTP URLs redirect to HTTPS, so just use the HTTPS scheme. + $uri->scheme('https'); + + return $uri; +} + +1; diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm index 4dd223a31..c003e9295 100644 --- a/Bugzilla/CGI.pm +++ b/Bugzilla/CGI.pm @@ -73,11 +73,22 @@ sub new { # Make sure our outgoing cookie list is empty on each invocation $self->{Bugzilla_cookie_list} = []; + # Path-Info is of no use for Bugzilla and interacts badly with IIS. + # Moreover, it causes unexpected behaviors, such as totally breaking + # the rendering of pages. + my $script = basename($0); + if ($self->path_info) { + my @whitelist = ("rest.cgi"); + Bugzilla::Hook::process('path_info_whitelist', { whitelist => \@whitelist }); + if (!grep($_ eq $script, @whitelist)) { + print $self->redirect($self->url(-path => 0, -query => 1)); + } + } + # Send appropriate charset $self->charset(Bugzilla->params->{'utf8'} ? 'UTF-8' : ''); # Redirect to urlbase/sslbase if we are not viewing an attachment. - my $script = basename($0); if ($self->url_is_attachment_base and $script ne 'attachment.cgi') { $self->redirect_to_urlbase(); } @@ -224,6 +235,26 @@ sub clean_search_url { } } +sub check_etag { + my ($self, $valid_etag) = @_; + + # ETag support. + my $if_none_match = $self->http('If-None-Match'); + return if !$if_none_match; + + my @if_none = split(/[\s,]+/, $if_none_match); + foreach my $possible_etag (@if_none) { + # remove quotes from begin and end of the string + $possible_etag =~ s/^\"//g; + $possible_etag =~ s/\"$//g; + if ($possible_etag eq $valid_etag or $possible_etag eq '*') { + return 1; + } + } + + return 0; +} + # Overwrite to ensure nph doesn't get set, and unset HEADERS_ONCE sub multipart_init { my $self = shift; @@ -316,6 +347,10 @@ sub header { unshift(@_, '-x_frame_options' => 'SAMEORIGIN'); } + if ($self->{'_content_disp'}) { + unshift(@_, '-content_disposition' => $self->{'_content_disp'}); + } + # Add X-XSS-Protection header to prevent simple XSS attacks # and enforce the blocking (rather than the rewriting) mode. unshift(@_, '-x_xss_protection' => '1; mode=block'); @@ -470,9 +505,9 @@ sub redirect_search_url { # GET requests that lacked a list_id are always redirected. POST requests # are only redirected if they're under the CGI_URI_LIMIT though. - my $uri_length = length($self->self_url()); - if ($self->request_method() ne 'POST' or $uri_length < CGI_URI_LIMIT) { - print $self->redirect(-url => $self->self_url()); + my $self_url = $self->self_url(); + if ($self->request_method() ne 'POST' or length($self_url) < CGI_URI_LIMIT) { + print $self->redirect(-url => $self_url); exit; } } @@ -526,7 +561,23 @@ sub url_is_attachment_base { $regex =~ s/\\\%bugid\\\%/\\d+/; } $regex = "^$regex"; - return ($self->self_url =~ $regex) ? 1 : 0; + return ($self->url =~ $regex) ? 1 : 0; +} + +sub set_dated_content_disp { + my ($self, $type, $prefix, $ext) = @_; + + my @time = localtime(time()); + my $date = sprintf "%04d-%02d-%02d", 1900+$time[5], $time[4]+1, $time[3]; + my $filename = "$prefix-$date.$ext"; + + $filename =~ s/\s/_/g; # Remove whitespace to avoid HTTP header tampering + $filename =~ s/\\/_/g; # Remove backslashes as well + $filename =~ s/"/\\"/g; # escape quotes + + my $disposition = "$type; filename=\"$filename\""; + + $self->{'_content_disp'} = $disposition; } ########################## @@ -636,6 +687,11 @@ instead of calling this directly. Redirects from the current URL to one prefixed by the urlbase parameter. +=item C<set_dated_content_disp> + +Sets an appropriate date-dependent value for the Content Disposition header +for a downloadable resource. + =back =head1 SEE ALSO diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm index ee342fb2d..623796142 100644 --- a/Bugzilla/Comment.pm +++ b/Bugzilla/Comment.pm @@ -26,11 +26,13 @@ package Bugzilla::Comment; use base qw(Bugzilla::Object); use Bugzilla::Attachment; +use Bugzilla::Comment::TagWeights; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::User; use Bugzilla::Util; +use List::Util qw(first); use Scalar::Util qw(blessed); ############################### @@ -92,21 +94,90 @@ use constant VALIDATOR_DEPENDENCIES => { sub update { my $self = shift; - my $changes = $self->SUPER::update(@_); - $self->bug->_sync_fulltext(); + my ($changes, $old_comment) = $self->SUPER::update(@_); + + if (exists $changes->{'thetext'} || exists $changes->{'isprivate'}) { + $self->bug->_sync_fulltext( update_comments => 1); + } + + my @old_tags = @{ $old_comment->tags }; + my @new_tags = @{ $self->tags }; + my ($removed_tags, $added_tags) = diff_arrays(\@old_tags, \@new_tags); + + if (@$removed_tags || @$added_tags) { + my $dbh = Bugzilla->dbh; + my $when = $dbh->selectrow_array("SELECT LOCALTIMESTAMP(0)"); + my $sth_delete = $dbh->prepare( + "DELETE FROM longdescs_tags WHERE comment_id = ? AND tag = ?" + ); + my $sth_insert = $dbh->prepare( + "INSERT INTO longdescs_tags(comment_id, tag) VALUES (?, ?)" + ); + my $sth_activity = $dbh->prepare( + "INSERT INTO longdescs_tags_activity + (bug_id, comment_id, who, bug_when, added, removed) + VALUES (?, ?, ?, ?, ?, ?)" + ); + + foreach my $tag (@$removed_tags) { + my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag }); + if ($weighted) { + if ($weighted->weight == 1) { + $weighted->remove_from_db(); + } else { + $weighted->set_weight($weighted->weight - 1); + $weighted->update(); + } + } + trick_taint($tag); + $sth_delete->execute($self->id, $tag); + $sth_activity->execute( + $self->bug_id, $self->id, Bugzilla->user->id, $when, '', $tag); + } + + foreach my $tag (@$added_tags) { + my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag }); + if ($weighted) { + $weighted->set_weight($weighted->weight + 1); + $weighted->update(); + } else { + Bugzilla::Comment::TagWeights->create({ tag => $tag, weight => 1 }); + } + trick_taint($tag); + $sth_insert->execute($self->id, $tag); + $sth_activity->execute( + $self->bug_id, $self->id, Bugzilla->user->id, $when, $tag, ''); + } + } + return $changes; } -# Speeds up displays of comment lists by loading all ->author objects -# at once for a whole list. +# Speeds up displays of comment lists by loading all author objects and tags at +# once for a whole list. sub preload { my ($class, $comments) = @_; + # Author my %user_ids = map { $_->{who} => 1 } @$comments; my $users = Bugzilla::User->new_from_list([keys %user_ids]); my %user_map = map { $_->id => $_ } @$users; foreach my $comment (@$comments) { $comment->{author} = $user_map{$comment->{who}}; } + # Tags + if (Bugzilla->params->{'comment_taggers_group'}) { + my $dbh = Bugzilla->dbh; + my @comment_ids = map { $_->id } @$comments; + my %comment_map = map { $_->id => $_ } @$comments; + my $rows = $dbh->selectall_arrayref( + "SELECT comment_id, " . $dbh->sql_group_concat('tag', "','") . " + FROM longdescs_tags + WHERE " . $dbh->sql_in('comment_id', \@comment_ids) . " + GROUP BY comment_id"); + foreach my $row (@$rows) { + $comment_map{$row->[0]}->{tags} = [ split(/,/, $row->[1]) ]; + } + } } ############################### @@ -126,6 +197,39 @@ sub work_time { sub type { return $_[0]->{'type'}; } sub extra_data { return $_[0]->{'extra_data'} } +sub tags { + my ($self) = @_; + return [] unless Bugzilla->params->{'comment_taggers_group'}; + $self->{'tags'} ||= Bugzilla->dbh->selectcol_arrayref( + "SELECT tag + FROM longdescs_tags + WHERE comment_id = ? + ORDER BY tag", + undef, $self->id); + return $self->{'tags'}; +} + +sub collapsed { + my ($self) = @_; + return 0 unless Bugzilla->params->{'comment_taggers_group'}; + return $self->{collapsed} if exists $self->{collapsed}; + $self->{collapsed} = 0; + Bugzilla->request_cache->{comment_tags_collapsed} + ||= [ split(/\s*,\s*/, Bugzilla->params->{'collapsed_comment_tags'}) ]; + my @collapsed_tags = @{ Bugzilla->request_cache->{comment_tags_collapsed} }; + foreach my $my_tag (@{ $self->tags }) { + $my_tag = lc($my_tag); + foreach my $collapsed_tag (@collapsed_tags) { + if ($my_tag eq lc($collapsed_tag)) { + $self->{collapsed} = 1; + last; + } + } + last if $self->{collapsed}; + } + return $self->{collapsed}; +} + sub bug { my $self = shift; require Bugzilla::Bug; @@ -143,14 +247,15 @@ sub is_about_attachment { sub attachment { my ($self) = @_; return undef if not $self->is_about_attachment; - $self->{attachment} ||= new Bugzilla::Attachment($self->extra_data); + $self->{attachment} ||= + new Bugzilla::Attachment({ id => $self->extra_data, cache => 1 }); return $self->{attachment}; } sub author { my $self = shift; - $self->{'author'} ||= new Bugzilla::User($self->{'who'}); - return $self->{'author'}; + return $self->{'author'} + ||= new Bugzilla::User({ id => $self->{'who'}, cache => 1 }); } sub body_full { @@ -181,6 +286,26 @@ sub set_is_private { $_[0]->set('isprivate', $_[1]); } sub set_type { $_[0]->set('type', $_[1]); } sub set_extra_data { $_[0]->set('extra_data', $_[1]); } +sub add_tag { + my ($self, $tag) = @_; + $tag = $self->_check_tag($tag); + + my $tags = $self->tags; + return if grep { lc($tag) eq lc($_) } @$tags; + push @$tags, $tag; + $self->{'tags'} = [ sort @$tags ]; +} + +sub remove_tag { + my ($self, $tag) = @_; + $tag = $self->_check_tag($tag); + + my $tags = $self->tags; + my $index = first { lc($tags->[$_]) eq lc($tag) } 0..scalar(@$tags) - 1; + return unless defined $index; + splice(@$tags, $index, 1); +} + ############## # Validators # ############## @@ -323,6 +448,17 @@ sub _check_isprivate { return $isprivate ? 1 : 0; } +sub _check_tag { + my ($invocant, $tag) = @_; + length($tag) < MIN_COMMENT_TAG_LENGTH + and ThrowUserError('comment_tag_too_short', { tag => $tag }); + length($tag) > MAX_COMMENT_TAG_LENGTH + and ThrowUserError('comment_tag_too_long', { tag => $tag }); + $tag =~ /^[\w\d\._-]+$/ + or ThrowUserError('comment_tag_invalid', { tag => $tag }); + return $tag; +} + sub count { my ($self) = @_; @@ -337,7 +473,7 @@ sub count { undef, $self->bug_id, $self->creation_ts); return --$self->{'count'}; -} +} 1; @@ -383,7 +519,7 @@ C<string> Time spent as related to this comment. =item C<is_private> -C<boolean> Comment is marked as private +C<boolean> Comment is marked as private. =item C<already_wrapped> @@ -398,6 +534,54 @@ L<Bugzilla::User> who created the comment. C<int> The position this comment is located in the full list of comments for a bug starting from 0. +=item C<collapsed> + +C<boolean> Comment should be displayed as collapsed by default. + +=item C<tags> + +C<array of strings> The tags attached to the comment. + +=item C<add_tag> + +=over + +=item B<Description> + +Attaches the specified tag to the comment. + +=item B<Params> + +=over + +=item C<tag> + +C<string> The tag to attach. + +=back + +=back + +=item C<remove_tag> + +=over + +=item B<Description> + +Detaches the specified tag from the comment. + +=item B<Params> + +=over + +=item C<tag> + +C<string> The tag to detach. + +=back + +=back + =item C<body_full> =over diff --git a/Bugzilla/Comment/TagWeights.pm b/Bugzilla/Comment/TagWeights.pm new file mode 100644 index 000000000..5835efbc4 --- /dev/null +++ b/Bugzilla/Comment/TagWeights.pm @@ -0,0 +1,74 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Comment::TagWeights; + +use 5.10.1; +use strict; + +use parent qw(Bugzilla::Object); + +use Bugzilla::Constants; + +# No auditing required +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; + +use constant DB_COLUMNS => qw( + id + tag + weight +); + +use constant UPDATE_COLUMNS => qw( + weight +); + +use constant DB_TABLE => 'longdescs_tags_weights'; +use constant ID_FIELD => 'id'; +use constant NAME_FIELD => 'tag'; +use constant LIST_ORDER => 'weight DESC'; +use constant VALIDATORS => { }; + +sub tag { return $_[0]->{'tag'} } +sub weight { return $_[0]->{'weight'} } + +sub set_weight { $_[0]->set('weight', $_[1]); } + +1; + +=head1 NAME + +Comment::TagWeights - Bugzilla comment weighting class. + +=head1 DESCRIPTION + +TagWeights.pm represents a Comment::TagWeight object. It is an implementation +of L<Bugzilla::Object>, and thus provides all methods that L<Bugzilla::Object> +provides. + +TagWeights is used to quickly find tags and order by their usage count. + +=head1 PROPERTIES + +=over + +=item C<tag> + +C<getter string> The tag + +=item C<weight> + +C<getter int> The tag's weight. The value returned corresponds to the number of +comments with this tag attached. + +=item C<set_weight> + +C<setter int> Set the tag's weight. + +=back diff --git a/Bugzilla/Component.pm b/Bugzilla/Component.pm index dc3cc1b9e..0fe6fb25a 100644 --- a/Bugzilla/Component.pm +++ b/Bugzilla/Component.pm @@ -352,30 +352,30 @@ sub bug_ids { sub default_assignee { my $self = shift; - - if (!defined $self->{'default_assignee'}) { - $self->{'default_assignee'} = - new Bugzilla::User($self->{'initialowner'}); - } - return $self->{'default_assignee'}; + return $self->{'default_assignee'} + ||= new Bugzilla::User({ id => $self->{'initialowner'}, cache => 1 }); } sub default_qa_contact { my $self = shift; if (!defined $self->{'default_qa_contact'}) { - $self->{'default_qa_contact'} = - new Bugzilla::User($self->{'initialqacontact'}); + my $params = $self->{'initialqacontact'} + ? { id => $self->{'initialqacontact'}, cache => 1 } + : $self->{'initialqacontact'}; + $self->{'default_qa_contact'} = new Bugzilla::User($params); } return $self->{'default_qa_contact'}; } sub flag_types { - my $self = shift; + my ($self, $params) = @_; + $params ||= {}; if (!defined $self->{'flag_types'}) { my $flagtypes = Bugzilla::FlagType::match({ product_id => $self->product_id, - component_id => $self->id }); + component_id => $self->id, + %$params }); $self->{'flag_types'} = {}; $self->{'flag_types'}->{'bug'} = diff --git a/Bugzilla/Config.pm b/Bugzilla/Config.pm index 990fd8dd2..3e9b793a6 100644 --- a/Bugzilla/Config.pm +++ b/Bugzilla/Config.pm @@ -35,7 +35,6 @@ use strict; use base qw(Exporter); use Bugzilla::Constants; use Bugzilla::Hook; -use Bugzilla::Install::Filesystem qw(fix_file_permissions); use Data::Dumper; use File::Temp; @@ -301,7 +300,10 @@ sub write_params { rename $tmpname, $param_file or die "Can't rename $tmpname to $param_file: $!"; - fix_file_permissions($param_file); + # It's not common to edit parameters and loading + # Bugzilla::Install::Filesystem is slow. + require Bugzilla::Install::Filesystem; + Bugzilla::Install::Filesystem::fix_file_permissions($param_file); # And now we have to reset the params cache so that Bugzilla will re-read # them. diff --git a/Bugzilla/Config/Advanced.pm b/Bugzilla/Config/Advanced.pm index 941cefc4f..5e51fbecc 100644 --- a/Bugzilla/Config/Advanced.pm +++ b/Bugzilla/Config/Advanced.pm @@ -63,6 +63,18 @@ use constant get_param_list => ( default => 'off', checker => \&check_multi }, + + { + name => 'disable_bug_updates', + type => 'b', + default => 0 + }, + + { + name => 'sentry_uri', + type => 't', + default => '', + }, ); 1; diff --git a/Bugzilla/Config/Auth.pm b/Bugzilla/Config/Auth.pm index a61cab5a2..d70c1f81e 100644 --- a/Bugzilla/Config/Auth.pm +++ b/Bugzilla/Config/Auth.pm @@ -97,6 +97,12 @@ sub get_param_list { }, { + name => 'webservice_email_filter', + type => 'b', + default => 0 + }, + + { name => 'emailregexp', type => 't', default => q:^[\\w\\.\\+\\-=]+@[\\w\\.\\-]+\\.[\\w\\-]+$:, diff --git a/Bugzilla/Config/BugFields.pm b/Bugzilla/Config/BugFields.pm index d0de9dac6..490d945ea 100644 --- a/Bugzilla/Config/BugFields.pm +++ b/Bugzilla/Config/BugFields.pm @@ -113,7 +113,13 @@ sub get_param_list { choices => ['', @legal_OS], default => '', checker => \&check_opsys - } ); + }, + + { + name => 'collapsed_comment_tags', + type => 't', + default => 'obsolete, spam', + }); return @param_list; } diff --git a/Bugzilla/Config/Common.pm b/Bugzilla/Config/Common.pm index 00c699217..edd5872e1 100644 --- a/Bugzilla/Config/Common.pm +++ b/Bugzilla/Config/Common.pm @@ -52,6 +52,7 @@ use base qw(Exporter); check_mail_delivery_method check_notification check_utf8 check_bug_status check_smtp_auth check_theschwartz_available check_maxattachmentsize check_email + check_comment_taggers_group ); # Checking functions for the various values @@ -369,6 +370,14 @@ sub check_theschwartz_available { return ""; } +sub check_comment_taggers_group { + my $group_name = shift; + if ($group_name && !Bugzilla->feature('jsonrpc')) { + return "Comment tagging requires installation of the JSONRPC feature"; + } + return check_group($group_name); +} + # OK, here are the parameter definitions themselves. # # Each definition is a hash with keys: @@ -467,4 +476,9 @@ Checks that the value is a valid number Checks that the value is a valid regexp +=item C<check_comment_taggers_group> + +Checks that the required modules for comment tagging are installed, and that a +valid group is provided. + =back diff --git a/Bugzilla/Config/GroupSecurity.pm b/Bugzilla/Config/GroupSecurity.pm index 6296583d9..24eddaf6b 100644 --- a/Bugzilla/Config/GroupSecurity.pm +++ b/Bugzilla/Config/GroupSecurity.pm @@ -79,7 +79,15 @@ sub get_param_list { default => 'editbugs', checker => \&check_group }, - + + { + name => 'comment_taggers_group', + type => 's', + choices => \&_get_all_group_names, + default => 'editbugs', + checker => \&check_comment_taggers_group + }, + { name => 'debug_group', type => 's', @@ -107,4 +115,5 @@ sub _get_all_group_names { unshift(@group_names, ''); return \@group_names; } + 1; diff --git a/Bugzilla/Constants.pm b/Bugzilla/Constants.pm index 9ffb325df..b7038505a 100644 --- a/Bugzilla/Constants.pm +++ b/Bugzilla/Constants.pm @@ -90,6 +90,9 @@ use Memoize; COMMENT_COLS MAX_COMMENT_LENGTH + MIN_COMMENT_TAG_LENGTH + MAX_COMMENT_TAG_LENGTH + CMT_NORMAL CMT_DUPE_OF CMT_HAS_DUPE @@ -105,7 +108,7 @@ use Memoize; POS_EVENTS EVT_OTHER EVT_ADDED_REMOVED EVT_COMMENT EVT_ATTACHMENT EVT_ATTACHMENT_DATA EVT_PROJ_MANAGEMENT EVT_OPENED_CLOSED EVT_KEYWORD EVT_CC EVT_DEPEND_BLOCK - EVT_BUG_CREATED + EVT_BUG_CREATED EVT_COMPONENT NEG_EVENTS EVT_UNCONFIRMED EVT_CHANGED_BY_ME @@ -125,10 +128,14 @@ use Memoize; FIELD_TYPE_MULTI_SELECT FIELD_TYPE_TEXTAREA FIELD_TYPE_DATETIME + FIELD_TYPE_DATE FIELD_TYPE_BUG_ID FIELD_TYPE_BUG_URLS FIELD_TYPE_KEYWORDS + FIELD_TYPE_EXTENSION + FIELD_TYPE_HIGHEST_PLUS_ONE + EMPTY_DATETIME_REGEX ABNORMAL_SELECTS @@ -141,12 +148,14 @@ use Memoize; USAGE_MODE_EMAIL USAGE_MODE_JSON USAGE_MODE_TEST + USAGE_MODE_REST ERROR_MODE_WEBPAGE ERROR_MODE_DIE ERROR_MODE_DIE_SOAP_FAULT ERROR_MODE_JSON_RPC ERROR_MODE_TEST + ERROR_MODE_REST COLOR_ERROR COLOR_SUCCESS @@ -182,6 +191,7 @@ use Memoize; MAX_FREETEXT_LENGTH MAX_BUG_URL_LENGTH MAX_POSSIBLE_DUPLICATES + MAX_WEBDOT_BUGS PASSWORD_DIGEST_ALGORITHM PASSWORD_SALT_LENGTH @@ -262,7 +272,8 @@ use constant AUTH_NO_SUCH_USER => 5; use constant AUTH_LOCKOUT => 6; # The minimum length a password must have. -use constant USER_PASSWORD_MIN_LENGTH => 6; +# BMO uses 8 characters. +use constant USER_PASSWORD_MIN_LENGTH => 8; use constant LOGIN_OPTIONAL => 0; use constant LOGIN_NORMAL => 1; @@ -303,6 +314,10 @@ use constant COMMENT_COLS => 80; # Used in _check_comment(). Gives the max length allowed for a comment. use constant MAX_COMMENT_LENGTH => 65535; +# The minimum and maximum length of comment tags. +use constant MIN_COMMENT_TAG_LENGTH => 3; +use constant MAX_COMMENT_TAG_LENGTH => 24; + # The type of bug comments. use constant CMT_NORMAL => 0; use constant CMT_DUPE_OF => 1; @@ -355,11 +370,13 @@ use constant EVT_KEYWORD => 7; use constant EVT_CC => 8; use constant EVT_DEPEND_BLOCK => 9; use constant EVT_BUG_CREATED => 10; +use constant EVT_COMPONENT => 11; use constant POS_EVENTS => EVT_OTHER, EVT_ADDED_REMOVED, EVT_COMMENT, EVT_ATTACHMENT, EVT_ATTACHMENT_DATA, EVT_PROJ_MANAGEMENT, EVT_OPENED_CLOSED, EVT_KEYWORD, - EVT_CC, EVT_DEPEND_BLOCK, EVT_BUG_CREATED; + EVT_CC, EVT_DEPEND_BLOCK, EVT_BUG_CREATED, + EVT_COMPONENT; use constant EVT_UNCONFIRMED => 50; use constant EVT_CHANGED_BY_ME => 51; @@ -389,7 +406,8 @@ use constant SENDMAIL_PATH => '/usr/lib:/usr/sbin:/usr/ucblib'; # only storage but also logic. For example, we might add a "user" field type # whose values are stored in an integer column in the database but for which # we do more than we would do for a standard integer type (f.e. we might -# display a user picker). +# display a user picker). Fields of type FIELD_TYPE_EXTENSION should generally +# be ignored by the core code and is used primary by extensions. use constant FIELD_TYPE_UNKNOWN => 0; use constant FIELD_TYPE_FREETEXT => 1; @@ -400,6 +418,12 @@ use constant FIELD_TYPE_DATETIME => 5; use constant FIELD_TYPE_BUG_ID => 6; use constant FIELD_TYPE_BUG_URLS => 7; use constant FIELD_TYPE_KEYWORDS => 8; +use constant FIELD_TYPE_DATE => 9; +use constant FIELD_TYPE_EXTENSION => 99; + +# Add new field types above this line, and change the below value in the +# obvious fashion +use constant FIELD_TYPE_HIGHEST_PLUS_ONE => 100; use constant EMPTY_DATETIME_REGEX => qr/^[0\-:\sA-Za-z]+$/; @@ -431,8 +455,8 @@ use constant MAX_LOGIN_ATTEMPTS => 5; use constant LOGIN_LOCKOUT_INTERVAL => 30; # The maximum number of seconds the Strict-Transport-Security header -# will remain valid. Default is one week. -use constant MAX_STS_AGE => 604800; +# will remain valid. BMO uses one year. +use constant MAX_STS_AGE => 31536000; # Protocols which are considered as safe. use constant SAFE_PROTOCOLS => ('afs', 'cid', 'ftp', 'gopher', 'http', 'https', @@ -445,15 +469,16 @@ use constant LEGAL_CONTENT_TYPES => ('application', 'audio', 'image', 'message', use constant contenttypes => { - "html"=> "text/html" , - "rdf" => "application/rdf+xml" , - "atom"=> "application/atom+xml" , - "xml" => "application/xml" , - "js" => "application/x-javascript" , - "json"=> "application/json" , - "csv" => "text/csv" , - "png" => "image/png" , - "ics" => "text/calendar" , + "html" => "text/html" , + "rdf" => "application/rdf+xml" , + "atom" => "application/atom+xml" , + "xml" => "application/xml" , + "dtd" => "application/xml-dtd" , + "js" => "application/x-javascript" , + "json" => "application/json" , + "csv" => "text/csv" , + "png" => "image/png" , + "ics" => "text/calendar" , }; # Usage modes. Default USAGE_MODE_BROWSER. Use with Bugzilla->usage_mode. @@ -463,6 +488,7 @@ use constant USAGE_MODE_XMLRPC => 2; use constant USAGE_MODE_EMAIL => 3; use constant USAGE_MODE_JSON => 4; use constant USAGE_MODE_TEST => 5; +use constant USAGE_MODE_REST => 6; # Error modes. Default set by Bugzilla->usage_mode (so ERROR_MODE_WEBPAGE # usually). Use with Bugzilla->error_mode. @@ -471,6 +497,7 @@ use constant ERROR_MODE_DIE => 1; use constant ERROR_MODE_DIE_SOAP_FAULT => 2; use constant ERROR_MODE_JSON_RPC => 3; use constant ERROR_MODE_TEST => 4; +use constant ERROR_MODE_REST => 5; # The ANSI colors of messages that command-line scripts use use constant COLOR_ERROR => 'red'; @@ -562,6 +589,9 @@ use constant MAX_BUG_URL_LENGTH => 255; # will return. use constant MAX_POSSIBLE_DUPLICATES => 25; +# Maximum number of bugs to display in a dependency graph +use constant MAX_WEBDOT_BUGS => 2000; + # This is the name of the algorithm used to hash passwords before storing # them in the database. This can be any string that is valid to pass to # Perl's "Digest" module. Note that if you change this, it won't take @@ -635,7 +665,7 @@ sub _bz_locations { # make sure this still points to the CGIs. 'cgi_path' => $libpath, 'templatedir' => "$libpath/template", - 'template_cache' => "$datadir/template", + 'template_cache' => "$libpath/template_cache", 'project' => $project, 'localconfig' => "$libpath/$localconfig", 'datadir' => $datadir, diff --git a/Bugzilla/DB.pm b/Bugzilla/DB.pm index 1f9c31518..4877471c7 100644 --- a/Bugzilla/DB.pm +++ b/Bugzilla/DB.pm @@ -159,7 +159,7 @@ sub _handle_error { # Cut down the error string to a reasonable size $_[0] = substr($_[0], 0, 2000) . ' ... ' . substr($_[0], -2000) if length($_[0]) > 4000; - $_[0] = Carp::longmess($_[0]); + # BMO: stracktrace disabled: $_[0] = Carp::longmess($_[0]); return 0; # Now let DBI handle raising the error } @@ -1384,7 +1384,7 @@ sub _bz_real_schema { return $self->{private_real_schema} if exists $self->{private_real_schema}; my ($data, $version) = $self->selectrow_array( - "SELECT schema_data, version FROM bz_schema"); + "SELECT SQL_CACHE schema_data, version FROM bz_schema"); (die "_bz_real_schema tried to read the bz_schema table but it's empty!") if !$data; diff --git a/Bugzilla/DB/Mysql.pm b/Bugzilla/DB/Mysql.pm index 9ddb46622..c430725ef 100644 --- a/Bugzilla/DB/Mysql.pm +++ b/Bugzilla/DB/Mysql.pm @@ -181,7 +181,7 @@ sub sql_fulltext_search { $mode = 'IN BOOLEAN MODE'; # quote un-quoted compound words - my @words = quotewords('[\s()]+', 'delimiters', $text); + my @words = grep { defined } quotewords('[\s()]+', 'delimiters', $text); foreach my $word (@words) { # match words that have non-word chars in the middle of them if ($word =~ /\w\W+\w/ && $word !~ m/"/) { diff --git a/Bugzilla/DB/Schema.pm b/Bugzilla/DB/Schema.pm index 1e598c61e..d8f3e175a 100644 --- a/Bugzilla/DB/Schema.pm +++ b/Bugzilla/DB/Schema.pm @@ -342,6 +342,8 @@ use constant ABSTRACT_SCHEMA => { bugs_activity => { FIELDS => [ + id => {TYPE => 'INTSERIAL', NOTNULL => 1, + PRIMARYKEY => 1}, bug_id => {TYPE => 'INT3', NOTNULL => 1, REFERENCES => {TABLE => 'bugs', COLUMN => 'bug_id', @@ -358,8 +360,8 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'fielddefs', COLUMN => 'id'}}, added => {TYPE => 'varchar(255)'}, - removed => {TYPE => 'TINYTEXT'}, - comment_id => {TYPE => 'INT3', + removed => {TYPE => 'varchar(255)'}, + comment_id => {TYPE => 'INT4', REFERENCES => { TABLE => 'longdescs', COLUMN => 'comment_id', DELETE => 'CASCADE'}}, @@ -370,6 +372,7 @@ use constant ABSTRACT_SCHEMA => { bugs_activity_bug_when_idx => ['bug_when'], bugs_activity_fieldid_idx => ['fieldid'], bugs_activity_added_idx => ['added'], + bugs_activity_removed_idx => ['removed'], ], }, @@ -393,7 +396,7 @@ use constant ABSTRACT_SCHEMA => { longdescs => { FIELDS => [ - comment_id => {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, + comment_id => {TYPE => 'INTSERIAL', NOTNULL => 1, PRIMARYKEY => 1}, bug_id => {TYPE => 'INT3', NOTNULL => 1, REFERENCES => {TABLE => 'bugs', @@ -421,6 +424,54 @@ use constant ABSTRACT_SCHEMA => { ], }, + longdescs_tags => { + FIELDS => [ + id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1 }, + comment_id => { TYPE => 'INT4', + REFERENCES => { TABLE => 'longdescs', + COLUMN => 'comment_id', + DELETE => 'CASCADE' }}, + tag => { TYPE => 'varchar(24)', NOTNULL => 1 }, + ], + INDEXES => [ + longdescs_tags_idx => { FIELDS => ['comment_id', 'tag'], TYPE => 'UNIQUE' }, + ], + }, + + longdescs_tags_weights => { + FIELDS => [ + id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1 }, + tag => { TYPE => 'varchar(24)', NOTNULL => 1 }, + weight => { TYPE => 'INT3', NOTNULL => 1 }, + ], + INDEXES => [ + longdescs_tags_weights_tag_idx => { FIELDS => ['tag'], TYPE => 'UNIQUE' }, + ], + }, + + longdescs_tags_activity => { + FIELDS => [ + id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1 }, + bug_id => { TYPE => 'INT3', NOTNULL => 1, + REFERENCES => { TABLE => 'bugs', + COLUMN => 'bug_id', + DELETE => 'CASCADE' }}, + comment_id => { TYPE => 'INT4', + REFERENCES => { TABLE => 'longdescs', + COLUMN => 'comment_id', + DELETE => 'CASCADE' }}, + who => { TYPE => 'INT3', NOTNULL => 1, + REFERENCES => { TABLE => 'profiles', + COLUMN => 'userid' }}, + bug_when => { TYPE => 'DATETIME', NOTNULL => 1 }, + added => { TYPE => 'varchar(24)' }, + removed => { TYPE => 'varchar(24)' }, + ], + INDEXES => [ + longdescs_tags_activity_bug_id_idx => ['bug_id'], + ], + }, + dependencies => { FIELDS => [ blocked => {TYPE => 'INT3', NOTNULL => 1, @@ -433,7 +484,8 @@ use constant ABSTRACT_SCHEMA => { DELETE => 'CASCADE'}}, ], INDEXES => [ - dependencies_blocked_idx => ['blocked'], + dependencies_blocked_idx => {FIELDS => [qw(blocked dependson)], + TYPE => 'UNIQUE'}, dependencies_dependson_idx => ['dependson'], ], }, @@ -466,6 +518,7 @@ use constant ABSTRACT_SCHEMA => { attachments_creation_ts_idx => ['creation_ts'], attachments_modification_time_idx => ['modification_time'], attachments_submitter_id_idx => ['submitter_id', 'bug_id'], + attachments_ispatch_idx => ['ispatch'], ], }, attach_data => { @@ -526,6 +579,9 @@ use constant ABSTRACT_SCHEMA => { added => {TYPE => 'MEDIUMTEXT'}, at_time => {TYPE => 'DATETIME', NOTNULL => 1}, ], + INDEXES => [ + audit_log_class_idx => ['class', 'at_time'], + ], }, # Keywords @@ -651,8 +707,8 @@ use constant ABSTRACT_SCHEMA => { DELETE => 'CASCADE'}}, ], INDEXES => [ - flaginclusions_type_id_idx => - [qw(type_id product_id component_id)], + flaginclusions_type_id_idx => { FIELDS => [qw(type_id product_id component_id)], + TYPE => 'UNIQUE' }, ], }, @@ -672,8 +728,8 @@ use constant ABSTRACT_SCHEMA => { DELETE => 'CASCADE'}}, ], INDEXES => [ - flagexclusions_type_id_idx => - [qw(type_id product_id component_id)], + flagexclusions_type_id_idx => { FIELDS => [qw(type_id product_id component_id)], + TYPE => 'UNIQUE' }, ], }, @@ -889,6 +945,7 @@ use constant ABSTRACT_SCHEMA => { extern_id => {TYPE => 'varchar(64)'}, is_enabled => {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE'}, + last_seen_date => {TYPE => 'DATETIME'}, ], INDEXES => [ profiles_login_name_idx => {FIELDS => ['login_name'], @@ -915,6 +972,8 @@ use constant ABSTRACT_SCHEMA => { profiles_activity => { FIELDS => [ + id => {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, + PRIMARYKEY => 1}, userid => {TYPE => 'INT3', NOTNULL => 1, REFERENCES => {TABLE => 'profiles', COLUMN => 'userid', @@ -952,6 +1011,23 @@ use constant ABSTRACT_SCHEMA => { ], }, + email_bug_ignore => { + FIELDS => [ + user_id => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE'}}, + bug_id => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'bugs', + COLUMN => 'bug_id', + DELETE => 'CASCADE'}}, + ], + INDEXES => [ + email_bug_ignore_user_id_idx => {FIELDS => [qw(user_id bug_id)], + TYPE => 'UNIQUE'}, + ], + }, + watch => { FIELDS => [ watcher => {TYPE => 'INT3', NOTNULL => 1, diff --git a/Bugzilla/DB/Schema/Mysql.pm b/Bugzilla/DB/Schema/Mysql.pm index 8c9ea2dda..5fc50a986 100644 --- a/Bugzilla/DB/Schema/Mysql.pm +++ b/Bugzilla/DB/Schema/Mysql.pm @@ -120,7 +120,7 @@ sub _initialize { LONGBLOB => 'longblob', DATETIME => 'datetime', - + DATE => 'date', }; $self->_adjust_schema; diff --git a/Bugzilla/DB/Schema/Oracle.pm b/Bugzilla/DB/Schema/Oracle.pm index a61b1e323..37b406671 100644 --- a/Bugzilla/DB/Schema/Oracle.pm +++ b/Bugzilla/DB/Schema/Oracle.pm @@ -70,7 +70,7 @@ sub _initialize { LONGBLOB => 'blob', DATETIME => 'date', - + DATE => 'date', }; $self->_adjust_schema; diff --git a/Bugzilla/DB/Schema/Pg.pm b/Bugzilla/DB/Schema/Pg.pm index d21f5099c..662120c03 100644 --- a/Bugzilla/DB/Schema/Pg.pm +++ b/Bugzilla/DB/Schema/Pg.pm @@ -80,7 +80,7 @@ sub _initialize { LONGBLOB => 'bytea', DATETIME => 'timestamp(0) without time zone', - + DATE => 'date', }; $self->_adjust_schema; diff --git a/Bugzilla/DB/Schema/Sqlite.pm b/Bugzilla/DB/Schema/Sqlite.pm index aad1f17bc..7ed9def3e 100644 --- a/Bugzilla/DB/Schema/Sqlite.pm +++ b/Bugzilla/DB/Schema/Sqlite.pm @@ -57,6 +57,7 @@ sub _initialize { LONGBLOB => 'blob', DATETIME => 'DATETIME', + DATE => 'DATETIME', }; $self->_adjust_schema; diff --git a/Bugzilla/Error.pm b/Bugzilla/Error.pm index 178f6f90c..41345e11b 100644 --- a/Bugzilla/Error.pm +++ b/Bugzilla/Error.pm @@ -26,8 +26,9 @@ package Bugzilla::Error; use strict; use base qw(Exporter); -@Bugzilla::Error::EXPORT = qw(ThrowCodeError ThrowTemplateError ThrowUserError); +@Bugzilla::Error::EXPORT = qw(ThrowCodeError ThrowTemplateError ThrowUserError ThrowErrorPage); +use Bugzilla::Sentry; use Bugzilla::Constants; use Bugzilla::WebService::Constants; use Bugzilla::Util; @@ -93,6 +94,7 @@ sub _throw_error { my $template = Bugzilla->template; my $message; + # There are some tests that throw and catch a lot of errors, # and calling $template->process over and over for those errors # is too slow. So instead, we just "die" with a dump of the arguments. @@ -108,8 +110,20 @@ sub _throw_error { message => \$message }); if (Bugzilla->error_mode == ERROR_MODE_WEBPAGE) { + if (sentry_should_notify($vars->{error})) { + $vars->{maintainers_notified} = 1; + $vars->{processed} = {}; + } else { + $vars->{maintainers_notified} = 0; + } + print Bugzilla->cgi->header(); - print $message; + $template->process($name, $vars) + || ThrowTemplateError($template->error()); + + if ($vars->{maintainers_notified}) { + sentry_handle_error($vars->{error}, $vars->{processed}->{error_message}); + } } elsif (Bugzilla->error_mode == ERROR_MODE_TEST) { die Dumper($vars); @@ -118,7 +132,8 @@ sub _throw_error { die("$message\n"); } elsif (Bugzilla->error_mode == ERROR_MODE_DIE_SOAP_FAULT - || Bugzilla->error_mode == ERROR_MODE_JSON_RPC) + || Bugzilla->error_mode == ERROR_MODE_JSON_RPC + || Bugzilla->error_mode == ERROR_MODE_REST) { # Clone the hash so we aren't modifying the constant. my %error_map = %{ WS_ERROR_CODE() }; @@ -135,13 +150,20 @@ sub _throw_error { } else { my $server = Bugzilla->_json_server; + + my $status_code = 0; + if (Bugzilla->error_mode == ERROR_MODE_REST) { + my %status_code_map = %{ REST_STATUS_CODE_MAP() }; + $status_code = $status_code_map{$code} || $status_code_map{'_default'}; + } # Technically JSON-RPC isn't allowed to have error numbers # higher than 999, but we do this to avoid conflicts with # the internal JSON::RPC error codes. - $server->raise_error(code => 100000 + $code, - message => $message, - id => $server->{_bz_request_id}, - version => $server->version); + $server->raise_error(code => 100000 + $code, + status_code => $status_code, + message => $message, + id => $server->{_bz_request_id}, + version => $server->version); # Most JSON-RPC Throw*Error calls happen within an eval inside # of JSON::RPC. So, in that circumstance, instead of exiting, # we die with no message. JSON::RPC checks raise_error before @@ -183,40 +205,83 @@ sub ThrowTemplateError { die("error: template error: $template_err"); } + # mod_perl overrides exit to call die with this string + # we never want to display this to the user + exit if $template_err =~ /\bModPerl::Util::exit\b/; + $vars->{'template_error_msg'} = $template_err; $vars->{'error'} = "template_error"; + sentry_handle_error('error', $template_err); + $vars->{'template_error_msg'} =~ s/ at \S+ line \d+\.\s*$//; + my $template = Bugzilla->template; # Try a template first; but if this one fails too, fall back # on plain old print statements. if (!$template->process("global/code-error.html.tmpl", $vars)) { - my $maintainer = Bugzilla->params->{'maintainer'}; + my $maintainer = html_quote(Bugzilla->params->{'maintainer'}); my $error = html_quote($vars->{'template_error_msg'}); my $error2 = html_quote($template->error()); print <<END; <tt> <p> - Bugzilla has suffered an internal error. Please save this page and - send it to $maintainer with details of what you were doing at the - time this message appeared. + Bugzilla has suffered an internal error: + </p> + <p> + $error + </p> + <!-- template error, no real need to show this to the user + $error2 + --> + <p> + The <a href="mailto:$maintainer">Bugzilla maintainers</a> have + been notified of this error. </p> - <script type="text/javascript"> <!-- - document.write("<p>URL: " + - document.location.href.replace(/&/g,"&") - .replace(/</g,"<") - .replace(/>/g,">") + "</p>"); - // --> - </script> - <p>Template->process() failed twice.<br> - First error: $error<br> - Second error: $error2</p> </tt> END } exit; } +sub ThrowErrorPage { + # BMO customisation for bug 659231 + my ($template_name, $message) = @_; + + my $dbh = Bugzilla->dbh; + $dbh->bz_rollback_transaction() if $dbh->bz_in_transaction(); + + if (Bugzilla->error_mode == ERROR_MODE_DIE) { + die("error: $message"); + } + + if (Bugzilla->error_mode == ERROR_MODE_DIE_SOAP_FAULT + || Bugzilla->error_mode == ERROR_MODE_JSON_RPC) + { + my $code = ERROR_UNKNOWN_TRANSIENT; + if (Bugzilla->error_mode == ERROR_MODE_DIE_SOAP_FAULT) { + die SOAP::Fault->faultcode($code)->faultstring($message); + } else { + my $server = Bugzilla->_json_server; + $server->raise_error(code => 100000 + $code, + message => $message, + id => $server->{_bz_request_id}, + version => $server->version); + die if _in_eval(); + $server->response($server->error_response_header); + } + } else { + my $cgi = Bugzilla->cgi; + my $template = Bugzilla->template; + my $vars = {}; + $vars->{message} = $message; + print $cgi->header(); + $template->process($template_name, $vars) + || ThrowTemplateError($template->error()); + exit; + } +} + 1; __END__ diff --git a/Bugzilla/Field.pm b/Bugzilla/Field.pm index 81677c7ea..5cd246b8e 100644 --- a/Bugzilla/Field.pm +++ b/Bugzilla/Field.pm @@ -78,6 +78,8 @@ use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Util; use List::MoreUtils qw(any); +use Bugzilla::Config qw(SetParam write_params); +use Bugzilla::Hook; use Scalar::Util qw(blessed); @@ -159,6 +161,7 @@ use constant SQL_DEFINITIONS => { FIELD_TYPE_TEXTAREA, { TYPE => 'MEDIUMTEXT', NOTNULL => 1, DEFAULT => "''"}, FIELD_TYPE_DATETIME, { TYPE => 'DATETIME' }, + FIELD_TYPE_DATE, { TYPE => 'DATE' }, FIELD_TYPE_BUG_ID, { TYPE => 'INT3' }, }; @@ -176,6 +179,9 @@ use constant DEFAULT_FIELDS => ( {name => 'product', desc => 'Product', in_new_bugmail => 1, is_mandatory => 1, type => FIELD_TYPE_SINGLE_SELECT, buglist => 1}, + {name => 'component', desc => 'Component', in_new_bugmail => 1, + is_mandatory => 1, + type => FIELD_TYPE_SINGLE_SELECT, buglist => 1}, {name => 'version', desc => 'Version', in_new_bugmail => 1, is_mandatory => 1, buglist => 1}, {name => 'rep_platform', desc => 'Platform', in_new_bugmail => 1, @@ -196,9 +202,6 @@ use constant DEFAULT_FIELDS => ( type => FIELD_TYPE_SINGLE_SELECT, buglist => 1}, {name => 'priority', desc => 'Priority', in_new_bugmail => 1, type => FIELD_TYPE_SINGLE_SELECT, buglist => 1}, - {name => 'component', desc => 'Component', in_new_bugmail => 1, - is_mandatory => 1, - type => FIELD_TYPE_SINGLE_SELECT, buglist => 1}, {name => 'assigned_to', desc => 'AssignedTo', in_new_bugmail => 1, buglist => 1}, {name => 'reporter', desc => 'ReportedBy', in_new_bugmail => 1, @@ -207,9 +210,9 @@ use constant DEFAULT_FIELDS => ( buglist => 1}, {name => 'cc', desc => 'CC', in_new_bugmail => 1}, {name => 'dependson', desc => 'Depends on', in_new_bugmail => 1, - is_numeric => 1}, + is_numeric => 1, buglist => 1}, {name => 'blocked', desc => 'Blocks', in_new_bugmail => 1, - is_numeric => 1}, + is_numeric => 1, buglist => 1}, {name => 'attachments.description', desc => 'Attachment description'}, {name => 'attachments.filename', desc => 'Attachment filename'}, @@ -261,6 +264,7 @@ use constant DEFAULT_FIELDS => ( {name => 'see_also', desc => "See Also", type => FIELD_TYPE_BUG_URLS}, {name => 'tag', desc => 'Tags'}, + {name => 'comment_tag', desc => 'Comment Tag'}, ); ################ @@ -347,9 +351,7 @@ sub _check_sortkey { sub _check_type { my ($invocant, $type, undef, $params) = @_; my $saved_type = $type; - # The constant here should be updated every time a new, - # higher field type is added. - (detaint_natural($type) && $type <= FIELD_TYPE_KEYWORDS) + (detaint_natural($type) && $type < FIELD_TYPE_HIGHEST_PLUS_ONE) || ThrowCodeError('invalid_customfield_type', { type => $saved_type }); my $custom = blessed($invocant) ? $invocant->custom : $params->{custom}; @@ -918,53 +920,67 @@ sub remove_from_db { ThrowUserError('customfield_not_obsolete', {'name' => $self->name }); } - $dbh->bz_start_transaction(); + # BMO: disable bug updates during field creation + # using an eval as try/finally + eval { + SetParam('disable_bug_updates', 1); + write_params(); - # Check to see if bug activity table has records (should be fast with index) - my $has_activity = $dbh->selectrow_array("SELECT COUNT(*) FROM bugs_activity - WHERE fieldid = ?", undef, $self->id); - if ($has_activity) { - ThrowUserError('customfield_has_activity', {'name' => $name }); - } + $dbh->bz_start_transaction(); + + # Check to see if bug activity table has records (should be fast with index) + my $has_activity = $dbh->selectrow_array("SELECT COUNT(*) FROM bugs_activity + WHERE fieldid = ?", undef, $self->id); + if ($has_activity) { + ThrowUserError('customfield_has_activity', {'name' => $name }); + } - # Check to see if bugs table has records (slow) - my $bugs_query = ""; + # Check to see if bugs table has records (slow) + my $bugs_query = ""; - if ($self->type == FIELD_TYPE_MULTI_SELECT) { - $bugs_query = "SELECT COUNT(*) FROM bug_$name"; - } - else { - $bugs_query = "SELECT COUNT(*) FROM bugs WHERE $name IS NOT NULL"; - if ($self->type != FIELD_TYPE_BUG_ID && $self->type != FIELD_TYPE_DATETIME) { - $bugs_query .= " AND $name != ''"; + if ($self->type == FIELD_TYPE_MULTI_SELECT) { + $bugs_query = "SELECT COUNT(*) FROM bug_$name"; } - # Ignore the default single select value - if ($self->type == FIELD_TYPE_SINGLE_SELECT) { - $bugs_query .= " AND $name != '---'"; + else { + $bugs_query = "SELECT COUNT(*) FROM bugs WHERE $name IS NOT NULL"; + if ($self->type != FIELD_TYPE_BUG_ID + && $self->type != FIELD_TYPE_DATE + && $self->type != FIELD_TYPE_DATETIME) + { + $bugs_query .= " AND $name != ''"; + } + # Ignore the default single select value + if ($self->type == FIELD_TYPE_SINGLE_SELECT) { + $bugs_query .= " AND $name != '---'"; + } } - } - my $has_bugs = $dbh->selectrow_array($bugs_query); - if ($has_bugs) { - ThrowUserError('customfield_has_contents', {'name' => $name }); - } + my $has_bugs = $dbh->selectrow_array($bugs_query); + if ($has_bugs) { + ThrowUserError('customfield_has_contents', {'name' => $name }); + } - # Once we reach here, we should be OK to delete. - $dbh->do('DELETE FROM fielddefs WHERE id = ?', undef, $self->id); + # Once we reach here, we should be OK to delete. + $dbh->do('DELETE FROM fielddefs WHERE id = ?', undef, $self->id); - my $type = $self->type; + my $type = $self->type; - # the values for multi-select are stored in a seperate table - if ($type != FIELD_TYPE_MULTI_SELECT) { - $dbh->bz_drop_column('bugs', $name); - } + # the values for multi-select are stored in a seperate table + if ($type != FIELD_TYPE_MULTI_SELECT) { + $dbh->bz_drop_column('bugs', $name); + } - if ($self->is_select) { - # Delete the table that holds the legal values for this field. - $dbh->bz_drop_field_tables($self); - } + if ($self->is_select) { + # Delete the table that holds the legal values for this field. + $dbh->bz_drop_field_tables($self); + } - $dbh->bz_commit_transaction() + $dbh->bz_commit_transaction(); + }; + my $error = "$@"; + SetParam('disable_bug_updates', 0); + write_params(); + die $error if $error; } =pod @@ -1012,48 +1028,67 @@ sub create { my ($params) = @_; my $dbh = Bugzilla->dbh; - # This makes sure the "sortkey" validator runs, even if - # the parameter isn't sent to create(). - $params->{sortkey} = undef if !exists $params->{sortkey}; - $params->{type} ||= 0; - # We mark the custom field as obsolete till it has been fully created, - # to avoid race conditions when viewing bugs at the same time. - my $is_obsolete = $params->{obsolete}; - $params->{obsolete} = 1 if $params->{custom}; - - $dbh->bz_start_transaction(); - $class->check_required_create_fields(@_); - my $field_values = $class->run_create_validators($params); - my $visibility_values = delete $field_values->{visibility_values}; - my $field = $class->insert_create_data($field_values); - - $field->set_visibility_values($visibility_values); - $field->_update_visibility_values(); + # BMO: disable bug updates during field creation + # using an eval as try/finally + my $field; + eval { + if ($params->{'custom'}) { + SetParam('disable_bug_updates', 1); + write_params(); + } - $dbh->bz_commit_transaction(); + # This makes sure the "sortkey" validator runs, even if + # the parameter isn't sent to create(). + $params->{sortkey} = undef if !exists $params->{sortkey}; + $params->{type} ||= 0; + # We mark the custom field as obsolete till it has been fully created, + # to avoid race conditions when viewing bugs at the same time. + my $is_obsolete = $params->{obsolete}; + $params->{obsolete} = 1 if $params->{custom}; + + $dbh->bz_start_transaction(); + $class->check_required_create_fields(@_); + my $field_values = $class->run_create_validators($params); + my $visibility_values = delete $field_values->{visibility_values}; + $field = $class->insert_create_data($field_values); + + $field->set_visibility_values($visibility_values); + $field->_update_visibility_values(); + + $dbh->bz_commit_transaction(); + + if ($field->custom) { + my $name = $field->name; + my $type = $field->type; + if (SQL_DEFINITIONS->{$type}) { + # Create the database column that stores the data for this field. + $dbh->bz_add_column('bugs', $name, SQL_DEFINITIONS->{$type}); + } - if ($field->custom) { - my $name = $field->name; - my $type = $field->type; - if (SQL_DEFINITIONS->{$type}) { - # Create the database column that stores the data for this field. - $dbh->bz_add_column('bugs', $name, SQL_DEFINITIONS->{$type}); - } + if ($field->is_select) { + # Create the table that holds the legal values for this field. + $dbh->bz_add_field_tables($field); + } - if ($field->is_select) { - # Create the table that holds the legal values for this field. - $dbh->bz_add_field_tables($field); - } + if ($type == FIELD_TYPE_SINGLE_SELECT) { + # Insert a default value of "---" into the legal values table. + $dbh->do("INSERT INTO $name (value) VALUES ('---')"); + } - if ($type == FIELD_TYPE_SINGLE_SELECT) { - # Insert a default value of "---" into the legal values table. - $dbh->do("INSERT INTO $name (value) VALUES ('---')"); + # Restore the original obsolete state of the custom field. + $dbh->do('UPDATE fielddefs SET obsolete = 0 WHERE id = ?', undef, $field->id) + unless $is_obsolete; } + }; - # Restore the original obsolete state of the custom field. - $dbh->do('UPDATE fielddefs SET obsolete = 0 WHERE id = ?', undef, $field->id) - unless $is_obsolete; + my $error = "$@"; + if ($params->{'custom'}) { + SetParam('disable_bug_updates', 0); + write_params(); } + die $error if $error; + + Bugzilla::Hook::process("field_end_of_create", { field => $field }); return $field; } diff --git a/Bugzilla/Flag.pm b/Bugzilla/Flag.pm index a727532a6..089ac6c9f 100644 --- a/Bugzilla/Flag.pm +++ b/Bugzilla/Flag.pm @@ -81,15 +81,21 @@ use constant AUDIT_REMOVES => 0; use constant SKIP_REQUESTEE_ON_ERROR => 1; -use constant DB_COLUMNS => qw( - id - type_id - bug_id - attach_id - requestee_id - setter_id - status -); +sub DB_COLUMNS { + my $dbh = Bugzilla->dbh; + return qw( + id + type_id + bug_id + attach_id + requestee_id + setter_id + status), + $dbh->sql_date_format('creation_date', '%Y.%m.%d %H:%i:%s') . + ' AS creation_date', + $dbh->sql_date_format('modification_date', '%Y.%m.%d %H:%i:%s') . + ' AS modification_date'; +} use constant UPDATE_COLUMNS => qw( requestee_id @@ -134,6 +140,14 @@ Returns the ID of the attachment this flag belongs to, if any. Returns the status '+', '-', '?' of the flag. +=item C<creation_date> + +Returns the timestamp when the flag was created. + +=item C<modification_date> + +Returns the timestamp when the flag was last modified. + =back =cut @@ -146,6 +160,8 @@ sub attach_id { return $_[0]->{'attach_id'}; } sub status { return $_[0]->{'status'}; } sub setter_id { return $_[0]->{'setter_id'}; } sub requestee_id { return $_[0]->{'requestee_id'}; } +sub creation_date { return $_[0]->{'creation_date'}; } +sub modification_date { return $_[0]->{'modification_date'}; } ############################### #### Methods #### @@ -180,22 +196,23 @@ is an attachment flag, else undefined. sub type { my $self = shift; - $self->{'type'} ||= new Bugzilla::FlagType($self->{'type_id'}); - return $self->{'type'}; + return $self->{'type'} + ||= new Bugzilla::FlagType($self->{'type_id'}, cache => 1 ); } sub setter { my $self = shift; - $self->{'setter'} ||= new Bugzilla::User($self->{'setter_id'}); - return $self->{'setter'}; + return $self->{'setter' } + ||= new Bugzilla::User({ id => $self->{'setter_id'}, cache => 1 }); } sub requestee { my $self = shift; if (!defined $self->{'requestee'} && $self->{'requestee_id'}) { - $self->{'requestee'} = new Bugzilla::User($self->{'requestee_id'}); + $self->{'requestee'} + = new Bugzilla::User({ id => $self->{'requestee_id'}, cache => 1 }); } return $self->{'requestee'}; } @@ -205,16 +222,16 @@ sub attachment { return undef unless $self->attach_id; require Bugzilla::Attachment; - $self->{'attachment'} ||= new Bugzilla::Attachment($self->attach_id); - return $self->{'attachment'}; + return $self->{'attachment'} + ||= new Bugzilla::Attachment({ id => $self->attach_id, cache => 1 }); } sub bug { my $self = shift; require Bugzilla::Bug; - $self->{'bug'} ||= new Bugzilla::Bug($self->bug_id); - return $self->{'bug'}; + return $self->{'bug'} + ||= new Bugzilla::Bug({ id => $self->bug_id, cache => 1 }); } ################################ @@ -284,7 +301,7 @@ sub count { sub set_flag { my ($class, $obj, $params) = @_; - my ($bug, $attachment); + my ($bug, $attachment, $obj_flag, $requestee_changed); if (blessed($obj) && $obj->isa('Bugzilla::Attachment')) { $attachment = $obj; $bug = $attachment->bug; @@ -296,6 +313,12 @@ sub set_flag { ThrowCodeError('flag_unexpected_object', { 'caller' => ref $obj }); } + # Make sure the user can change flags + my $privs; + $bug->check_can_change_field('flagtypes.name', 0, 1, \$privs) + || ThrowUserError('illegal_change', + { field => 'flagtypes.name', privs => $privs }); + # Update (or delete) an existing flag. if ($params->{id}) { my $flag = $class->check({ id => $params->{id} }); @@ -322,13 +345,14 @@ sub set_flag { ($obj_flagtype) = grep { $_->id == $flag->type_id } @{$obj->flag_types}; push(@{$obj_flagtype->{flags}}, $flag); } - my ($obj_flag) = grep { $_->id == $flag->id } @{$obj_flagtype->{flags}}; + ($obj_flag) = grep { $_->id == $flag->id } @{$obj_flagtype->{flags}}; # If the flag has the correct type but cannot be found above, this means # the flag is going to be removed (e.g. because this is a pending request # and the attachment is being marked as obsolete). return unless $obj_flag; - $class->_validate($obj_flag, $obj_flagtype, $params, $bug, $attachment); + ($obj_flag, $requestee_changed) = + $class->_validate($obj_flag, $obj_flagtype, $params, $bug, $attachment); } # Create a new flag. elsif ($params->{type_id}) { @@ -360,12 +384,21 @@ sub set_flag { } } - $class->_validate(undef, $obj_flagtype, $params, $bug, $attachment); + ($obj_flag, $requestee_changed) = + $class->_validate(undef, $obj_flagtype, $params, $bug, $attachment); } else { ThrowCodeError('param_required', { function => $class . '->set_flag', param => 'id/type_id' }); } + + if ($obj_flag + && $requestee_changed + && $obj_flag->requestee_id + && $obj_flag->requestee->setting('requestee_cc') eq 'on') + { + $bug->add_cc($obj_flag->requestee); + } } sub _validate { @@ -383,25 +416,27 @@ sub _validate { my $old_requestee_id = $obj_flag->requestee_id; $obj_flag->_set_status($params->{status}); - $obj_flag->_set_requestee($params->{requestee}, $attachment, $params->{skip_roe}); + $obj_flag->_set_requestee($params->{requestee}, $bug, $attachment, $params->{skip_roe}); + + # The requestee ID can be undefined. + my $requestee_changed = ($obj_flag->requestee_id || 0) != ($old_requestee_id || 0); # The setter field MUST NOT be updated if neither the status # nor the requestee fields changed. - if (($obj_flag->status ne $old_status) - # The requestee ID can be undefined. - || (($obj_flag->requestee_id || 0) != ($old_requestee_id || 0))) - { + if (($obj_flag->status ne $old_status) || $requestee_changed) { $obj_flag->_set_setter($params->{setter}); } # If the flag is deleted, remove it from the list. if ($obj_flag->status eq 'X') { @{$flag_type->{flags}} = grep { $_->id != $obj_flag->id } @{$flag_type->{flags}}; + return; } # Add the newly created flag to the list. elsif (!$obj_flag->id) { push(@{$flag_type->{flags}}, $obj_flag); } + return wantarray ? ($obj_flag, $requestee_changed) : $obj_flag; } =pod @@ -418,10 +453,14 @@ Creates a flag record in the database. sub create { my ($class, $flag, $timestamp) = @_; - $timestamp ||= Bugzilla->dbh->selectrow_array('SELECT NOW()'); + $timestamp ||= Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); my $params = {}; my @columns = grep { $_ ne 'id' } $class->_get_db_columns; + + # Some columns use date formatting so use alias instead + @columns = map { /\s+AS\s+(.*)$/ ? $1 : $_ } @columns; + $params->{$_} = $flag->{$_} foreach @columns; $params->{creation_date} = $params->{modification_date} = $timestamp; @@ -440,6 +479,7 @@ sub update { if (scalar(keys %$changes)) { $dbh->do('UPDATE flags SET modification_date = ? WHERE id = ?', undef, ($timestamp, $self->id)); + $self->{'modification_date'} = format_time($timestamp, '%Y.%m.%d %T'); } return $changes; } @@ -602,10 +642,10 @@ sub force_retarget { ############################### sub _set_requestee { - my ($self, $requestee, $attachment, $skip_requestee_on_error) = @_; + my ($self, $requestee, $bug, $attachment, $skip_requestee_on_error) = @_; $self->{requestee} = - $self->_check_requestee($requestee, $attachment, $skip_requestee_on_error); + $self->_check_requestee($requestee, $bug, $attachment, $skip_requestee_on_error); $self->{requestee_id} = $self->{requestee} ? $self->{requestee}->id : undef; @@ -627,7 +667,7 @@ sub _set_status { } sub _check_requestee { - my ($self, $requestee, $attachment, $skip_requestee_on_error) = @_; + my ($self, $requestee, $bug, $attachment, $skip_requestee_on_error) = @_; # If the flag status is not "?", then no requestee can be defined. return undef if ($self->status ne '?'); @@ -647,15 +687,29 @@ sub _check_requestee { # is specifically requestable. For existing flags, if the requestee # was set before the flag became specifically unrequestable, the # user can either remove him or leave him alone. - ThrowCodeError('flag_requestee_disabled', { type => $self->type }) + ThrowCodeError('flag_type_requestee_disabled', { type => $self->type }) if !$self->type->is_requesteeble; + # BMO customisation: + # You can't ask a disabled account, as they don't have the ability to + # set the flag. + ThrowUserError('flag_requestee_disabled', { requestee => $requestee }) + if !$requestee->is_enabled; + # Make sure the requestee can see the bug. # Note that can_see_bug() will query the DB, so if the bug # is being added/removed from some groups and these changes # haven't been committed to the DB yet, they won't be taken - # into account here. In this case, old restrictions matters. - if (!$requestee->can_see_bug($self->bug_id)) { + # into account here. In this case, old group restrictions matter. + # However, if the user has just been changed to the assignee, + # qa_contact, or added to the cc list of the bug and the bug + # is cclist_accessible, the requestee is allowed. + if (!$requestee->can_see_bug($self->bug_id) + && (!$bug->cclist_accessible + || !grep($_->id == $requestee->id, @{ $bug->cc_users }) + && $requestee->id != $bug->assigned_to->id + && (!$bug->qa_contact || $requestee->id != $bug->qa_contact->id))) + { if ($skip_requestee_on_error) { undef $requestee; } @@ -955,7 +1009,7 @@ sub notify { my %recipients; foreach my $cc (split(/[, ]+/, $cc_list)) { - my $ccuser = new Bugzilla::User({ name => $cc }); + my $ccuser = new Bugzilla::User({ name => $cc, cache => 1 }); next if (scalar(@bug_in_groups) && (!$ccuser || !$ccuser->can_see_bug($bug->bug_id))); next if $attachment_is_private && (!$ccuser || !$ccuser->is_insider); # Prevent duplicated entries due to case sensitivity. diff --git a/Bugzilla/FlagType.pm b/Bugzilla/FlagType.pm index 811530c42..910e2d425 100644 --- a/Bugzilla/FlagType.pm +++ b/Bugzilla/FlagType.pm @@ -52,6 +52,7 @@ use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Util; use Bugzilla::Group; +use Bugzilla::Hook; use base qw(Bugzilla::Object); @@ -133,6 +134,8 @@ sub create { exclusions => $exclusions }); $flagtype->update(); + Bugzilla::Hook::process('flagtype_end_of_create', { type => $flagtype }); + $dbh->bz_commit_transaction(); return $flagtype; } @@ -201,6 +204,9 @@ sub update { undef, $self->id); } + Bugzilla::Hook::process('flagtype_end_of_update', + { type => $self, changed => $changes }); + $dbh->bz_commit_transaction(); return $changes; } @@ -462,7 +468,8 @@ sub grant_list { my @custusers; my @allusers = @{Bugzilla->user->get_userlist}; foreach my $user (@allusers) { - my $user_obj = new Bugzilla::User({name => $user->{login}}); + my $user_obj + = new Bugzilla::User({ name => $user->{login}, cache => 1 }); push(@custusers, $user) if $user_obj->can_set_flag($self); } return \@custusers; @@ -601,7 +608,7 @@ sub match { $tables = join(' ', @$tables); $criteria = join(' AND ', @criteria); - my $flagtype_ids = $dbh->selectcol_arrayref("SELECT id FROM $tables WHERE $criteria"); + my $flagtype_ids = $dbh->selectcol_arrayref("SELECT flagtypes.id FROM $tables WHERE $criteria"); return Bugzilla::FlagType->new_from_list($flagtype_ids); } @@ -679,6 +686,11 @@ sub sqlify_criteria { my $is_active = $criteria->{is_active} ? "1" : "0"; push(@criteria, "flagtypes.is_active = $is_active"); } + if (exists($criteria->{active_or_has_flags}) && $criteria->{active_or_has_flags} =~ /^\d+$/) { + push(@$tables, "LEFT JOIN flags AS f ON flagtypes.id = f.type_id " . + "AND f.bug_id = " . $criteria->{active_or_has_flags}); + push(@criteria, "(flagtypes.is_active = 1 OR f.id IS NOT NULL)"); + } if ($criteria->{product_id}) { my $product_id = $criteria->{product_id}; detaint_natural($product_id) diff --git a/Bugzilla/Group.pm b/Bugzilla/Group.pm index 382407748..109f06d7f 100644 --- a/Bugzilla/Group.pm +++ b/Bugzilla/Group.pm @@ -119,9 +119,10 @@ sub _get_members { } sub flag_types { - my $self = shift; + my ($self, $params) = @_; + $params ||= {}; require Bugzilla::FlagType; - $self->{flag_types} ||= Bugzilla::FlagType::match({ group => $self->id }); + $self->{flag_types} ||= Bugzilla::FlagType::match({ group => $self->id, %$params }); return $self->{flag_types}; } diff --git a/Bugzilla/Hook.pm b/Bugzilla/Hook.pm index c658989a0..a18b11f77 100644 --- a/Bugzilla/Hook.pm +++ b/Bugzilla/Hook.pm @@ -434,6 +434,39 @@ to the user. =back +=head2 bug_start_of_update + +This happens near the beginning of L<Bugzilla::Bug/update>, after L<Bugzilla::Object/update> +is called, but before all other special changes are made to the database. Once use case is +this allows for adding your own entries to the C<changes> hash which gets added to the +bugs_activity table later keeping you from having to do it yourself. Also this is also helpful +if your extension needs to add CC members, flags, keywords, groups, etc. This generally +occurs inside a database transaction. + +Params: + +=over + +=item C<bug> + +The changed bug object, with all fields set to their updated values. + +=item C<old_bug> + +A bug object pulled from the database before the fields were set to +their updated values (so it has the old values available for each field). + +=item C<timestamp> + +The timestamp used for all updates in this transaction, as a SQL date +string. + +=item C<changes> + +The hash of changed fields. C<< $changes->{field} = [old, new] >> + +=back + =head2 buglist_columns This happens in L<Bugzilla::Search/COLUMNS>, which determines legal bug @@ -1289,6 +1322,22 @@ your template. =back +=head2 path_info_whitelist + +By default, Bugzilla removes the Path-Info information from URLs before +passing data to CGI scripts. If this information is needed for your +customizations, you can enumerate the pages you want to whitelist here. + +Params: + +=over + +=item C<whitelist> + +An array of script names that will not have their Path-Info automatically +removed. + +=back =head2 post_bug_after_creation diff --git a/Bugzilla/Install.pm b/Bugzilla/Install.pm index ce8fe6bad..6019c9d18 100644 --- a/Bugzilla/Install.pm +++ b/Bugzilla/Install.pm @@ -93,6 +93,10 @@ sub SETTINGS { # 2011-06-21 glob@mozilla.com -- Bug 589128 email_format => { options => ['html', 'text_only'], default => 'html' }, + # 2011-06-16 glob@mozilla.com -- Bug 663747 + bugmail_new_prefix => { options => ['on', 'off'], default => 'on' }, + # 2011-10-11 glob@mozilla.com -- Bug 301656 + requestee_cc => { options => ['on', 'off'], default => 'on' }, } }; diff --git a/Bugzilla/Install/DB.pm b/Bugzilla/Install/DB.pm index 3ac83775a..ad68e94c4 100644 --- a/Bugzilla/Install/DB.pm +++ b/Bugzilla/Install/DB.pm @@ -398,7 +398,7 @@ sub update_table_definitions { "WHERE initialqacontact = 0"); _migrate_email_prefs_to_new_table(); - _initialize_dependency_tree_changes_email_pref(); + _initialize_new_email_prefs(); _change_all_mysql_booleans_to_tinyint(); # make classification_id field type be consistent with DB:Schema @@ -455,7 +455,7 @@ sub update_table_definitions { # 2005-12-07 altlst@sonic.net -- Bug 225221 $dbh->bz_add_column('longdescs', 'comment_id', - {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); + {TYPE => 'INTSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); _stop_storing_inactive_flags(); _change_short_desc_from_mediumtext_to_varchar(); @@ -607,7 +607,7 @@ sub update_table_definitions { _fix_series_creator_fk(); # 2009-11-14 dkl@redhat.com - Bug 310450 - $dbh->bz_add_column('bugs_activity', 'comment_id', {TYPE => 'INT3'}); + $dbh->bz_add_column('bugs_activity', 'comment_id', {TYPE => 'INT4'}); # 2010-04-07 LpSolit@gmail.com - Bug 69621 $dbh->bz_drop_column('bugs', 'keywords'); @@ -660,6 +660,9 @@ sub update_table_definitions { # 2011-10-11 miketosh - Bug 690173 _on_delete_set_null_for_audit_log_userid(); + # 2011-11-01 glob@mozilla.com - Bug 240437 + $dbh->bz_add_column('profiles', 'last_seen_date', {TYPE => 'DATETIME'}); + # 2011-11-28 dkl@mozilla.com - Bug 685611 _fix_notnull_defaults(); @@ -669,6 +672,39 @@ sub update_table_definitions { $dbh->bz_add_index('profile_search', 'profile_search_user_id_idx', [qw(user_id)]); } + # 2012-06-06 dkl@mozilla.com - Bug 762288 + $dbh->bz_alter_column('bugs_activity', 'removed', + { TYPE => 'varchar(255)' }); + $dbh->bz_add_index('bugs_activity', 'bugs_activity_removed_idx', ['removed']); + + # 2012-06-13 dkl@mozilla.com - Bug 764457 + $dbh->bz_add_column('bugs_activity', 'id', + {TYPE => 'INTSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); + + # 2012-06-13 dkl@mozilla.com - Bug 764466 + $dbh->bz_add_column('profiles_activity', 'id', + {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); + + # 2012-07-24 dkl@mozilla.com - Bug 776972 + $dbh->bz_alter_column('bugs_activity', 'id', + {TYPE => 'INTSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); + + + # 2012-07-24 dkl@mozilla.com - Bug 776982 + _fix_longdescs_primary_key(); + + # 2012-08-02 dkl@mozilla.com - Bug 756953 + _fix_dependencies_dupes(); + + # 2013-02-04 dkl@mozilla.com - Bug 824346 + _fix_flagclusions_indexes(); + + # 2012-04-15 Frank@Frank-Becker.de - Bug 740536 + $dbh->bz_add_index('audit_log', 'audit_log_class_idx', ['class', 'at_time']); + + # 2013-08-16 glob@mozilla.com - Bug 905925 + $dbh->bz_add_index('attachments', 'attachments_ispatch_idx', ['ispatch']); + ################################################################ # New --TABLE-- changes should go *** A B O V E *** this point # ################################################################ @@ -2396,13 +2432,16 @@ sub _migrate_email_prefs_to_new_table { } } -sub _initialize_dependency_tree_changes_email_pref { +sub _initialize_new_email_prefs { my $dbh = Bugzilla->dbh; # Check for any "new" email settings that wouldn't have been ported over # during the block above. Since these settings would have otherwise # fallen under EVT_OTHER, we'll just clone those settings. That way if # folks have already disabled all of that mail, there won't be any change. - my %events = ("Dependency Tree Changes" => EVT_DEPEND_BLOCK); + my %events = ( + "Dependency Tree Changes" => EVT_DEPEND_BLOCK, + "Product/Component Changes" => EVT_COMPONENT, + ); foreach my $desc (keys %events) { my $event = $events{$desc}; @@ -3220,6 +3259,11 @@ sub _populate_bugs_fulltext { print "Populating bugs_fulltext with $num_bugs entries..."; print " (this can take a long time.)\n"; } + + # As recommended by Monty Widenius for GNOME's upgrade. + # mkanat and justdave concur it'll be helpful for bmo, too. + $dbh->do('SET SESSION myisam_sort_buffer_size = 3221225472'); + my $newline = $dbh->quote("\n"); $dbh->do( qq{$command INTO bugs_fulltext (bug_id, short_desc, comments, @@ -3682,6 +3726,70 @@ sub _fix_notnull_defaults { } } +sub _fix_longdescs_primary_key { + my $dbh = Bugzilla->dbh; + if ($dbh->bz_column_info('longdescs', 'comment_id')->{TYPE} ne 'INTSERIAL') { + $dbh->bz_drop_related_fks('longdescs', 'comment_id'); + $dbh->bz_alter_column('bugs_activity', 'comment_id', {TYPE => 'INT4'}); + $dbh->bz_alter_column('longdescs', 'comment_id', + {TYPE => 'INTSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); + } +} + +sub _fix_dependencies_dupes { + my $dbh = Bugzilla->dbh; + my $blocked_idx = $dbh->bz_index_info('dependencies', 'dependencies_blocked_idx'); + if ($blocked_idx && scalar @{$blocked_idx->{'FIELDS'}} < 2) { + # Remove duplicated entries + my $dupes = $dbh->selectall_arrayref(" + SELECT blocked, dependson, COUNT(*) AS count + FROM dependencies " . + $dbh->sql_group_by('blocked, dependson') . " + HAVING COUNT(*) > 1", + { Slice => {} }); + print "Removing duplicated entries from the 'dependencies' table...\n" if @$dupes; + foreach my $dupe (@$dupes) { + $dbh->do("DELETE FROM dependencies + WHERE blocked = ? AND dependson = ?", + undef, $dupe->{blocked}, $dupe->{dependson}); + $dbh->do("INSERT INTO dependencies (blocked, dependson) VALUES (?, ?)", + undef, $dupe->{blocked}, $dupe->{dependson}); + } + $dbh->bz_drop_index('dependencies', 'dependencies_blocked_idx'); + $dbh->bz_add_index('dependencies', 'dependencies_blocked_idx', + { FIELDS => [qw(blocked dependson)], TYPE => 'UNIQUE' }); + } +} + +sub _fix_flagclusions_indexes { + my $dbh = Bugzilla->dbh; + foreach my $table ('flaginclusions', 'flagexclusions') { + my $index = $table . '_type_id_idx'; + my $idx_info = $dbh->bz_index_info($table, $index); + if ($idx_info && $idx_info->{'TYPE'} ne 'UNIQUE') { + # Remove duplicated entries + my $dupes = $dbh->selectall_arrayref(" + SELECT type_id, product_id, component_id, COUNT(*) AS count + FROM $table " . + $dbh->sql_group_by('type_id, product_id, component_id') . " + HAVING COUNT(*) > 1", + { Slice => {} }); + print "Removing duplicated entries from the '$table' table...\n" if @$dupes; + foreach my $dupe (@$dupes) { + $dbh->do("DELETE FROM $table + WHERE type_id = ? AND product_id = ? AND component_id = ?", + undef, $dupe->{type_id}, $dupe->{product_id}, $dupe->{component_id}); + $dbh->do("INSERT INTO $table (type_id, product_id, component_id) VALUES (?, ?, ?)", + undef, $dupe->{type_id}, $dupe->{product_id}, $dupe->{component_id}); + } + $dbh->bz_drop_index($table, $index); + $dbh->bz_add_index($table, $index, + { FIELDS => [qw(type_id product_id component_id)], + TYPE => 'UNIQUE' }); + } + } +} + 1; __END__ diff --git a/Bugzilla/Install/Filesystem.pm b/Bugzilla/Install/Filesystem.pm index c5215ecfa..1abac0154 100644 --- a/Bugzilla/Install/Filesystem.pm +++ b/Bugzilla/Install/Filesystem.pm @@ -159,6 +159,7 @@ sub FILESYSTEM { 'runtests.pl' => { perms => OWNER_EXECUTE }, 'jobqueue.pl' => { perms => OWNER_EXECUTE }, 'migrate.pl' => { perms => OWNER_EXECUTE }, + 'sentry.pl' => { perms => OWNER_EXECUTE }, 'install-module.pl' => { perms => OWNER_EXECUTE }, 'Bugzilla.pm' => { perms => CGI_READ }, @@ -170,6 +171,7 @@ sub FILESYSTEM { 'contrib/README' => { perms => OWNER_WRITE }, 'contrib/*/README' => { perms => OWNER_WRITE }, + 'contrib/sendunsentbugmail.pl' => { perms => WS_EXECUTE }, 'docs/bugzilla.ent' => { perms => OWNER_WRITE }, 'docs/makedocs.pl' => { perms => OWNER_EXECUTE }, 'docs/style.css' => { perms => WS_SERVE }, @@ -179,13 +181,16 @@ sub FILESYSTEM { "$datadir/old-params.txt" => { perms => OWNER_WRITE }, "$extensionsdir/create.pl" => { perms => OWNER_EXECUTE }, "$extensionsdir/*/*.pl" => { perms => WS_EXECUTE }, + "$extensionsdir/*/bin/*" => { perms => WS_EXECUTE }, ); # Directories that we want to set the perms on, but not # recurse through. These are directories we didn't create # in checkesetup.pl. + # + # Purpose of BMO change: unknown. my %non_recurse_dirs = ( - '.' => DIR_WS_SERVE, + '.' => 0755, docs => DIR_WS_SERVE, ); @@ -243,10 +248,13 @@ sub FILESYSTEM { dirs => DIR_WS_SERVE }, "$extensionsdir/*/web" => { files => WS_SERVE, dirs => DIR_WS_SERVE }, - + + # Purpose: allow webserver to read .bzr so we execute bzr commands + # in backticks and look at the result over the web. Used to show + # bzr history. + '.bzr' => { files => WS_SERVE, + dirs => DIR_WS_SERVE }, # Directories only for the owner, not for the webserver. - '.bzr' => { files => OWNER_WRITE, - dirs => DIR_OWNER_WRITE }, t => { files => OWNER_WRITE, dirs => DIR_OWNER_WRITE }, xt => { files => OWNER_WRITE, diff --git a/Bugzilla/Install/Requirements.pm b/Bugzilla/Install/Requirements.pm index 2e67457ab..d52b576df 100644 --- a/Bugzilla/Install/Requirements.pm +++ b/Bugzilla/Install/Requirements.pm @@ -288,7 +288,7 @@ sub OPTIONAL_MODULES { package => 'JSON-RPC', module => 'JSON::RPC', version => 0, - feature => ['jsonrpc'], + feature => ['jsonrpc', 'rest'], }, { package => 'JSON-XS', @@ -301,7 +301,7 @@ sub OPTIONAL_MODULES { package => 'Test-Taint', module => 'Test::Taint', version => 0, - feature => ['jsonrpc', 'xmlrpc'], + feature => ['jsonrpc', 'xmlrpc', 'rest'], }, { # We need the 'utf8_mode' method of HTML::Parser, for HTML::Scrubber. @@ -385,6 +385,7 @@ use constant FEATURE_FILES => ( jsonrpc => ['Bugzilla/WebService/Server/JSONRPC.pm', 'jsonrpc.cgi'], xmlrpc => ['Bugzilla/WebService/Server/XMLRPC.pm', 'xmlrpc.cgi', 'Bugzilla/WebService.pm', 'Bugzilla/WebService/*.pm'], + rest => ['Bugzilla/WebService/Server/REST.pm', 'rest.cgi'], moving => ['importxml.pl'], auth_ldap => ['Bugzilla/Auth/Verify/LDAP.pm'], auth_radius => ['Bugzilla/Auth/Verify/RADIUS.pm'], diff --git a/Bugzilla/Install/Util.pm b/Bugzilla/Install/Util.pm index bd8942507..5f6c8bceb 100644 --- a/Bugzilla/Install/Util.pm +++ b/Bugzilla/Install/Util.pm @@ -382,7 +382,10 @@ sub include_languages { # Basically, the way this works is that we have a list of languages # that we *want*, and a list of languages that Bugzilla actually - # supports. + # supports. If there is only one language installed, we take it. + my $supported = supported_languages(); + return @$supported if @$supported == 1; + my $wanted; if ($params->{language}) { # We can pass several languages at once as an arrayref @@ -393,7 +396,6 @@ sub include_languages { else { $wanted = _wanted_languages(); } - my $supported = supported_languages(); my $actual = _wanted_to_actual_languages($wanted, $supported); return @$actual; } diff --git a/Bugzilla/Instrument.pm b/Bugzilla/Instrument.pm new file mode 100644 index 000000000..4ab74ff8e --- /dev/null +++ b/Bugzilla/Instrument.pm @@ -0,0 +1,68 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Instrument; + +use strict; +use warnings; + +use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC); +use Encode qw(encode_utf8); +use Sys::Syslog qw(:DEFAULT); + +sub new { + my ($class, $label) = @_; + my $self = bless({ times => [], labels => [], values => [] }, $class); + $self->label($label); + $self->time('start_time'); + return $self; +} + +sub time { + my ($self, $name) = @_; + # for now $name isn't used + push @{ $self->{times} }, clock_gettime(CLOCK_MONOTONIC); +} + +sub label { + my ($self, $value) = @_; + push @{ $self->{labels} }, $value; +} + +sub value { + my ($self, $name, $value) = @_; + # for now $name isn't used + push @{ $self->{values} }, $value; +} + +sub log { + my $self = shift; + + my @times = @{ $self->{times} }; + return unless scalar(@times) >= 2; + my @labels = @{ $self->{labels} }; + my @values = @{ $self->{values} }; + + # calculate diffs + my @diffs = ($times[$#times] - $times[0]); + while (1) { + my $start = shift(@times); + last unless scalar(@times); + push @diffs, $times[0] - $start; + } + + # build syslog string + my $format = '[timing]' . (' %s' x scalar(@labels)) . (' %.6f' x scalar(@diffs)) . (' %s' x scalar(@values)); + my $entry = sprintf($format, @labels, @diffs, @values); + + # and log + openlog('apache', 'cons,pid', 'local4'); + syslog('notice', encode_utf8($entry)); + closelog(); +} + +1; diff --git a/Bugzilla/Job/BugMail.pm b/Bugzilla/Job/BugMail.pm new file mode 100644 index 000000000..9c176b005 --- /dev/null +++ b/Bugzilla/Job/BugMail.pm @@ -0,0 +1,31 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Job::BugMail; + +use 5.10.1; +use strict; + +use Bugzilla::BugMail; +BEGIN { eval "use parent qw(Bugzilla::Job::Mailer)"; } + +sub work { + my ($class, $job) = @_; + my $success = eval { + Bugzilla::BugMail::dequeue($job->arg->{vars}); + 1; + }; + if (!$success) { + $job->failed($@); + undef $@; + } + else { + $job->completed; + } +} + +1; diff --git a/Bugzilla/JobQueue.pm b/Bugzilla/JobQueue.pm index 7ea678345..a94598b87 100644 --- a/Bugzilla/JobQueue.pm +++ b/Bugzilla/JobQueue.pm @@ -27,12 +27,15 @@ use strict; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Install::Util qw(install_string); +use File::Slurp; use base qw(TheSchwartz); +use fields qw(_worker_pidfile); # This maps job names for Bugzilla::JobQueue to the appropriate modules. # If you add new types of jobs, you should add a mapping here. use constant JOB_MAP => { send_mail => 'Bugzilla::Job::Mailer', + bug_mail => 'Bugzilla::Job::BugMail', }; # Without a driver cache TheSchwartz opens a new database connection @@ -99,6 +102,64 @@ sub insert { return $retval; } +# To avoid memory leaks/fragmentation which tends to happen for long running +# perl processes; check for jobs, and spawn a new process to empty the queue. +sub subprocess_worker { + my $self = shift; + + my $command = "$0 -p '" . $self->{_worker_pidfile} . "' onepass"; + + while (1) { + my $time = (time); + my @jobs = $self->list_jobs({ + funcname => $self->{all_abilities}, + run_after => $time, + grabbed_until => $time, + limit => 1, + }); + if (@jobs) { + $self->debug("Spawning queue worker process"); + # Run the worker as a daemon + system $command; + # And poll the PID to detect when the working has finished. + # We do this instead of system() to allow for the INT signal to + # interrup us and trigger kill_worker(). + my $pid = read_file($self->{_worker_pidfile}, err_mode => 'quiet'); + if ($pid) { + sleep(3) while(kill(0, $pid)); + } + $self->debug("Queue worker process completed"); + } else { + $self->debug("No jobs found"); + } + sleep(5); + } +} + +sub kill_worker { + my $self = Bugzilla->job_queue(); + if ($self->{_worker_pidfile} && -e $self->{_worker_pidfile}) { + my $worker_pid = read_file($self->{_worker_pidfile}); + if ($worker_pid && kill(0, $worker_pid)) { + $self->debug("Stopping worker process"); + system "$0 -f -p '" . $self->{_worker_pidfile} . "' stop"; + } + } +} + +sub set_pidfile { + my ($self, $pidfile) = @_; + $pidfile =~ s/^(.+)(\..+)$/$1.worker$2/; + $self->{_worker_pidfile} = $pidfile; +} + +# Clear the request cache at the start of each run. +sub work_once { + my $self = shift; + Bugzilla->clear_request_cache(); + return $self->SUPER::work_once(@_); +} + 1; __END__ diff --git a/Bugzilla/JobQueue/Runner.pm b/Bugzilla/JobQueue/Runner.pm index 26755e78f..d45c36647 100644 --- a/Bugzilla/JobQueue/Runner.pm +++ b/Bugzilla/JobQueue/Runner.pm @@ -51,6 +51,7 @@ our $initscript = "bugzilla-queue"; sub gd_preconfig { my $self = shift; + $self->{_run_command} = 'subprocess_worker'; my $pidfile = $self->{gd_args}{pidfile}; if (!$pidfile) { $pidfile = bz_locations()->{datadir} . '/' . $self->{gd_progname} @@ -196,21 +197,26 @@ sub gd_setup_signals { $SIG{TERM} = sub { $self->gd_quit_event(); } } -sub gd_other_cmd { - my ($self) = shift; - if ($ARGV[0] eq "once") { - $self->_do_work("work_once"); +sub gd_quit_event { + Bugzilla->job_queue->kill_worker(); + exit(1); +} - exit(0); +sub gd_other_cmd { + my ($self, $do, $locked) = @_; + if ($do eq "once") { + $self->{_run_command} = 'work_once'; + } elsif ($do eq "onepass") { + $self->{_run_command} = 'work_until_done'; + } else { + $self->SUPER::gd_other_cmd($do, $locked); } - - $self->SUPER::gd_other_cmd(); } sub gd_run { my $self = shift; - - $self->_do_work("work"); + $SIG{__DIE__} = \&Carp::confess if $self->{debug}; + $self->_do_work($self->{_run_command}); } sub _do_work { @@ -218,6 +224,7 @@ sub _do_work { my $jq = Bugzilla->job_queue(); $jq->set_verbose($self->{debug}); + $jq->set_pidfile($self->{gd_pidfile}); foreach my $module (values %{ Bugzilla::JobQueue->job_map() }) { eval "use $module"; $jq->can_do($module); diff --git a/Bugzilla/Mailer.pm b/Bugzilla/Mailer.pm index 1c4fb6188..381422821 100644 --- a/Bugzilla/Mailer.pm +++ b/Bugzilla/Mailer.pm @@ -55,6 +55,7 @@ BEGIN { $Return::Value::NO_CLUCK = 1; } use Email::Send; +use Sys::Hostname; sub MessageToMTA { my ($msg, $send_now) = (@_); @@ -93,29 +94,6 @@ sub MessageToMTA { # thus to hopefully avoid auto replies. $email->header_set('Auto-Submitted', 'auto-generated'); - $email->walk_parts(sub { - my ($part) = @_; - return if $part->parts > 1; # Top-level - my $content_type = $part->content_type || ''; - $content_type =~ /charset=['"](.+)['"]/; - # If no charset is defined or is the default us-ascii, - # then we encode the email to UTF-8 if Bugzilla has utf8 enabled. - # XXX - This is a hack to workaround bug 723944. - if (!$1 || $1 eq 'us-ascii') { - my $body = $part->body; - if (Bugzilla->params->{'utf8'}) { - $part->charset_set('UTF-8'); - # encoding_set works only with bytes, not with utf8 strings. - my $raw = $part->body_raw; - if (utf8::is_utf8($raw)) { - utf8::encode($raw); - $part->body_set($raw); - } - } - $part->encoding_set('quoted-printable') if !is_7bit_clean($body); - } - }); - # MIME-Version must be set otherwise some mailsystems ignore the charset $email->header_set('MIME-Version', '1.0') if !$email->header('MIME-Version'); @@ -140,7 +118,9 @@ sub MessageToMTA { my $from = $email->header('From'); my ($hostname, @args); + my $mailer_class = $method; if ($method eq "Sendmail") { + $mailer_class = 'Bugzilla::Send::Sendmail'; if (ON_WINDOWS) { $Email::Send::Sendmail::SENDMAIL = SENDMAIL_EXE; } @@ -169,6 +149,12 @@ sub MessageToMTA { } } + # For tracking/diagnostic purposes, add our hostname + my $generated_by = $email->header('X-Generated-By') || ''; + if ($generated_by =~ tr/\/// < 3) { + $email->header_set('X-Generated-By' => $generated_by . '/' . hostname() . "($$)"); + } + if ($method eq "SMTP") { push @args, Host => Bugzilla->params->{"smtpserver"}, username => Bugzilla->params->{"smtp_username"}, @@ -180,6 +166,32 @@ sub MessageToMTA { Bugzilla::Hook::process('mailer_before_send', { email => $email, mailer_args => \@args }); + # Allow for extensions to to drop the bugmail by clearing the 'to' header + return if $email->header('to') eq ''; + + $email->walk_parts(sub { + my ($part) = @_; + return if $part->parts > 1; # Top-level + my $content_type = $part->content_type || ''; + $content_type =~ /charset=['"](.+)['"]/; + # If no charset is defined or is the default us-ascii, + # then we encode the email to UTF-8 if Bugzilla has utf8 enabled. + # XXX - This is a hack to workaround bug 723944. + if (!$1 || $1 eq 'us-ascii') { + my $body = $part->body; + if (Bugzilla->params->{'utf8'}) { + $part->charset_set('UTF-8'); + # encoding_set works only with bytes, not with utf8 strings. + my $raw = $part->body_raw; + if (utf8::is_utf8($raw)) { + utf8::encode($raw); + $part->body_set($raw); + } + } + $part->encoding_set('quoted-printable') if !is_7bit_clean($body); + } + }); + if ($method eq "Test") { my $filename = bz_locations()->{'datadir'} . '/mailer.testfile'; open TESTFILE, '>>', $filename; @@ -190,7 +202,7 @@ sub MessageToMTA { else { # This is useful for both Sendmail and Qmail, so we put it out here. local $ENV{PATH} = SENDMAIL_PATH; - my $mailer = Email::Send->new({ mailer => $method, + my $mailer = Email::Send->new({ mailer => $mailer_class, mailer_args => \@args }); my $retval = $mailer->send($email); ThrowCodeError('mail_send_error', { msg => $retval, mail => $email }) diff --git a/Bugzilla/Migrate.pm b/Bugzilla/Migrate.pm index ee0dcab95..923fc36a4 100644 --- a/Bugzilla/Migrate.pm +++ b/Bugzilla/Migrate.pm @@ -459,8 +459,11 @@ sub translate_value { } my $field_obj = $self->bug_fields->{$field}; - if ($field eq 'creation_ts' or $field eq 'delta_ts' - or ($field_obj and $field_obj->type == FIELD_TYPE_DATETIME)) + if ($field eq 'creation_ts' + or $field eq 'delta_ts' + or ($field_obj and + ($field_obj->type == FIELD_TYPE_DATETIME + or $field_obj->type == FIELD_TYPE_DATE))) { $value = trim($value); return undef if !$value; @@ -827,7 +830,7 @@ sub _insert_comments { $self->_do_table_insert('longdescs', \%copy); $self->debug(" Inserted comment from " . $who->login, 2); } - $bug->_sync_fulltext(); + $bug->_sync_fulltext( update_comments => 1 ); } sub _insert_history { diff --git a/Bugzilla/Object.pm b/Bugzilla/Object.pm index d4574abd2..4525fa78a 100644 --- a/Bugzilla/Object.pm +++ b/Bugzilla/Object.pm @@ -59,8 +59,14 @@ sub TO_JSON { return { %{ $_[0] } }; } sub new { my $invocant = shift; my $class = ref($invocant) || $invocant; - my $object = $class->_init(@_); - bless($object, $class) if $object; + my $param = shift; + + my $object = $class->_cache_get($param); + return $object if $object; + + $object = $class->new_from_hash($class->_load_from_db($param)); + $class->_cache_set($param, $object); + return $object; } @@ -69,7 +75,7 @@ sub new { # Bugzilla::Object, make sure that you modify bz_setup_database # in Bugzilla::DB::Pg appropriately, to add the right LOWER # index. You can see examples already there. -sub _init { +sub _load_from_db { my $class = shift; my ($param) = @_; my $dbh = Bugzilla->dbh; @@ -82,19 +88,19 @@ sub _init { if (ref $param eq 'HASH') { $id = $param->{id}; } - my $object; + my $object_data; if (defined $id) { # We special-case if somebody specifies an ID, so that we can # validate it as numeric. detaint_natural($id) || ThrowCodeError('param_must_be_numeric', - {function => $class . '::_init'}); + {function => $class . '::_load_from_db'}); # Too large integers make PostgreSQL crash. return if $id > MAX_INT_32; - $object = $dbh->selectrow_hashref(qq{ + $object_data = $dbh->selectrow_hashref(qq{ SELECT $columns FROM $table WHERE $id_field = ?}, undef, $id); } else { @@ -121,11 +127,94 @@ sub _init { } map { trick_taint($_) } @values; - $object = $dbh->selectrow_hashref( + $object_data = $dbh->selectrow_hashref( "SELECT $columns FROM $table WHERE $condition", undef, @values); } + return $object_data; +} - return $object; +sub new_from_list { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my ($id_list) = @_; + my $id_field = $class->ID_FIELD; + + my @detainted_ids; + foreach my $id (@$id_list) { + detaint_natural($id) || + ThrowCodeError('param_must_be_numeric', + {function => $class . '::new_from_list'}); + # Too large integers make PostgreSQL crash. + next if $id > MAX_INT_32; + push(@detainted_ids, $id); + } + + # We don't do $invocant->match because some classes have + # their own implementation of match which is not compatible + # with this one. However, match() still needs to have the right $invocant + # in order to do $class->DB_TABLE and so on. + return match($invocant, { $id_field => \@detainted_ids }); +} + +sub new_from_hash { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my $object_data = shift || return; + $class->_serialisation_keys($object_data); + bless($object_data, $class); + $object_data->initialize(); + return $object_data; +} + +sub initialize { + # abstract +} + +# Provides a mechanism for objects to be cached in the request_cahce + +sub _cache_get { + my $class = shift; + my ($param) = @_; + my $cache_key = $class->cache_key($param) + || return; + return Bugzilla->request_cache->{$cache_key}; +} + +sub _cache_set { + my $class = shift; + my ($param, $object) = @_; + my $cache_key = $class->cache_key($param) + || return; + Bugzilla->request_cache->{$cache_key} = $object; +} + +sub _cache_remove { + my $class = shift; + my ($param, $object) = @_; + $param->{cache} = 1; + my $cache_key = $class->cache_key($param) + || return; + delete Bugzilla->request_cache->{$cache_key}; +} + +sub cache_key { + my $class = shift; + my ($param) = @_; + if (ref($param) && $param->{cache} && ($param->{id} || $param->{name})) { + $class = blessed($class) if blessed($class); + return $class . ',' . ($param->{id} || $param->{name}); + } else { + return; + } +} + +# To support serialisation, we need to capture the keys in an object's default +# hashref. +sub _serialisation_keys { + my ($class, $object) = @_; + my $cache = Bugzilla->request_cache->{serialisation_keys} ||= {}; + $cache->{$class} = [ keys %$object ] if $object && !exists $cache->{$class}; + return @{ $cache->{$class} }; } sub check { @@ -161,28 +250,6 @@ sub check { return $obj; } -sub new_from_list { - my $invocant = shift; - my $class = ref($invocant) || $invocant; - my ($id_list) = @_; - my $id_field = $class->ID_FIELD; - - my @detainted_ids; - foreach my $id (@$id_list) { - detaint_natural($id) || - ThrowCodeError('param_must_be_numeric', - {function => $class . '::new_from_list'}); - # Too large integers make PostgreSQL crash. - next if $id > MAX_INT_32; - push(@detainted_ids, $id); - } - # We don't do $invocant->match because some classes have - # their own implementation of match which is not compatible - # with this one. However, match() still needs to have the right $invocant - # in order to do $class->DB_TABLE and so on. - return match($invocant, { $id_field => \@detainted_ids }); -} - # Note: Future extensions to this could be: # * Add a MATCH_JOIN constant so that we can join against # certain other tables for the WHERE criteria. @@ -228,8 +295,11 @@ sub match { } next; } - - $class->_check_field($field, 'match'); + + # It's always safe to use the field defined by classes as being + # their ID field. In particular, this means that new_from_list() + # is exempted from this check. + $class->_check_field($field, 'match') unless $field eq $class->ID_FIELD; if (ref $value eq 'ARRAY') { # IN () is invalid SQL, and if we have an empty list @@ -278,8 +348,11 @@ sub _do_list_select { my @untainted = @{ $values || [] }; trick_taint($_) foreach @untainted; my $objects = $dbh->selectall_arrayref($sql, {Slice=>{}}, @untainted); - bless ($_, $class) foreach @$objects; - return $objects + $class->_serialisation_keys($objects->[0]) if @$objects; + foreach my $object (@$objects) { + $object = $class->new_from_hash($object); + } + return $objects; } ############################### @@ -332,12 +405,17 @@ sub set_all { my %field_values = %$params; my @sorted_names = $self->_sort_by_dep(keys %field_values); + foreach my $key (@sorted_names) { # It's possible for one set_ method to delete a key from $params # for another set method, so if that's happened, we don't call the # other set method. next if !exists $field_values{$key}; my $method = "set_$key"; + if (!$self->can($method)) { + my $class = ref($self) || $self; + ThrowCodeError("unknown_method", { method => "${class}::${method}" }); + } $self->$method($field_values{$key}, \%field_values); } Bugzilla::Hook::process('object_end_of_set_all', @@ -398,6 +476,8 @@ sub update { $self->audit_log(\%changes) if $self->AUDIT_UPDATES; $dbh->bz_commit_transaction(); + $self->_cache_remove({ id => $self->id }); + $self->_cache_remove({ name => $self->name }) if $self->name; if (wantarray) { return (\%changes, $old_self); @@ -416,6 +496,8 @@ sub remove_from_db { $self->audit_log(AUDIT_REMOVE) if $self->AUDIT_REMOVES; $dbh->do("DELETE FROM $table WHERE $id_field = ?", undef, $self->id); $dbh->bz_commit_transaction(); + $self->_cache_remove({ id => $self->id }); + $self->_cache_remove({ name => $self->name }) if $self->name; undef $self; } @@ -450,6 +532,13 @@ sub audit_log { } } +sub flatten_to_hash { + my $self = shift; + my $class = blessed($self); + my %hash = map { $_ => $self->{$_} } $class->_serialisation_keys; + return \%hash; +} + ############################### #### Subroutines ###### ############################### @@ -977,6 +1066,17 @@ database matching the parameters you passed in. =back +=item C<initialize> + +=over + +=item B<Description> + +Abstract method to allow subclasses to perform initialization tasks after an +object has been created. + +=back + =item C<check> =over diff --git a/Bugzilla/PatchReader.pm b/Bugzilla/PatchReader.pm new file mode 100644 index 000000000..b5c3b957b --- /dev/null +++ b/Bugzilla/PatchReader.pm @@ -0,0 +1,117 @@ +package Bugzilla::PatchReader; + +use strict; + +=head1 NAME + +PatchReader - Utilities to read and manipulate patches and CVS + +=head1 SYNOPSIS + + # Script that reads in a patch (in any known format), and prints + # out some information about it. Other common operations are + # outputting the patch in a raw unified diff format, outputting + # the patch information to Template::Toolkit templates, adding + # context to a patch from CVS, and narrowing the patch down to + # apply only to a single file or set of files. + + use PatchReader::Raw; + use PatchReader::PatchInfoGrabber; + my $filename = 'filename.patch'; + + # Create the reader that parses the patch and the object that + # extracts info from the reader's datastream + my $reader = new PatchReader::Raw(); + my $patch_info_grabber = new PatchReader::PatchInfoGrabber(); + $reader->sends_data_to($patch_info_grabber); + + # Iterate over the file + $reader->iterate_file($filename); + + # Print the output + my $patch_info = $patch_info_grabber->patch_info(); + print "Summary of Changed Files:\n"; + while (my ($file, $info) = each %{$patch_info->{files}}) { + print "$file: +$info->{plus_lines} -$info->{minus_lines}\n"; + } + +=head1 ABSTRACT + +This perl library allows you to manipulate patches programmatically by +chaining together a variety of objects that read, manipulate, and output +patch information: + +=over + +=item PatchReader::Raw + +Parse a patch in any format known to this author (unified, normal, cvs diff, +among others) + +=item PatchReader::PatchInfoGrabber + +Grab summary info for sections of a patch in a nice hash + +=item PatchReader::AddCVSContext + +Add context to the patch by grabbing the original files from CVS + +=item PatchReader::NarrowPatch + +Narrow a patch down to only apply to a specific set of files + +=item PatchReader::DiffPrinter::raw + +Output the parsed patch in raw unified diff format + +=item PatchReader::DiffPrinter::template + +Output the parsed patch to L<Template::Toolkit> templates (can be used to make +HTML output or anything else you please) + +=back + +Additionally, it is designed so that you can plug in your own objects that +read the parsed data while it is being parsed (no need for the performance or +memory problems that can come from reading in the entire patch all at once). +You can do this by mimicking one of the existing readers (such as +PatchInfoGrabber) and overriding the methods start_patch, start_file, section, +end_file and end_patch. + +=head1 AUTHORS + + John Keiser <jkeiser@cpan.org> + Teemu Mannermaa <tmannerm@cpan.org> + +=head1 COPYRIGHT AND LICENSE + + Copyright (C) 2003-2004, John Keiser and + Copyright (C) 2011-2012, Teemu Mannermaa. + +This module is free software; you can redistribute it and/or modify it under +the terms of the Artistic License 1.0. For details, see the full text of the +license at + <http://www.perlfoundation.org/artistic_license_1_0>. + +This module is distributed in the hope that it will be useful, but it is +provided “as is” and without any warranty; without even the implied warranty +of merchantability or fitness for a particular purpose. + +Files with different licenses or copyright holders: + +=over + +=item F<lib/PatchReader/CVSClient.pm> + +Portions created by Netscape are +Copyright (C) 2003, Netscape Communications Corporation. All rights reserved. + +This file is subject to the terms of the Mozilla Public License, v. 2.0. + +=back + +=cut + +$Bugzilla::PatchReader::VERSION = '0.9.7'; + +1 diff --git a/Bugzilla/PatchReader/AddCVSContext.pm b/Bugzilla/PatchReader/AddCVSContext.pm new file mode 100644 index 000000000..910e45669 --- /dev/null +++ b/Bugzilla/PatchReader/AddCVSContext.pm @@ -0,0 +1,226 @@ +package Bugzilla::PatchReader::AddCVSContext; + +use Bugzilla::PatchReader::FilterPatch; +use Bugzilla::PatchReader::CVSClient; +use Cwd; +use File::Temp; + +use strict; + +@Bugzilla::PatchReader::AddCVSContext::ISA = qw(Bugzilla::PatchReader::FilterPatch); + +# XXX If you need to, get the entire patch worth of files and do a single +# cvs update of all files as soon as you find a file where you need to do a +# cvs update, to avoid the significant connect overhead +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = $class->SUPER::new(); + bless $this, $class; + + $this->{CONTEXT} = $_[0]; + $this->{CVSROOT} = $_[1]; + + return $this; +} + +sub my_rmtree { + my ($this, $dir) = @_; + foreach my $file (glob("$dir/*")) { + if (-d $file) { + $this->my_rmtree($file); + } else { + trick_taint($file); + unlink $file; + } + } + trick_taint($dir); + rmdir $dir; +} + +sub end_patch { + my $this = shift; + if (exists($this->{TMPDIR})) { + # Set as variable to get rid of taint + # One would like to use rmtree here, but that is not taint-safe. + $this->my_rmtree($this->{TMPDIR}); + } +} + +sub start_file { + my $this = shift; + my ($file) = @_; + $this->{HAS_CVS_CONTEXT} = !$file->{is_add} && !$file->{is_remove} && + $file->{old_revision}; + $this->{REVISION} = $file->{old_revision}; + $this->{FILENAME} = $file->{filename}; + $this->{SECTION_END} = -1; + $this->{TARGET}->start_file(@_) if $this->{TARGET}; +} + +sub end_file { + my $this = shift; + $this->flush_section(); + + if ($this->{FILE}) { + close $this->{FILE}; + unlink $this->{FILE}; # If it fails, it fails ... + delete $this->{FILE}; + } + $this->{TARGET}->end_file(@_) if $this->{TARGET}; +} + +sub next_section { + my $this = shift; + my ($section) = @_; + $this->{NEXT_PATCH_LINE} = $section->{old_start}; + $this->{NEXT_NEW_LINE} = $section->{new_start}; + foreach my $line (@{$section->{lines}}) { + # If this is a line requiring context ... + if ($line =~ /^[-\+]/) { + # Determine how much context is needed for both the previous section line + # and this one: + # - If there is no old line, start new section + # - If this is file context, add (old section end to new line) context to + # the existing section + # - If old end context line + 1 < new start context line, there is an empty + # space and therefore we end the old section and start the new one + # - Else we add (old start context line through new line) context to + # existing section + if (! exists($this->{SECTION})) { + $this->_start_section(); + } elsif ($this->{CONTEXT} eq "file") { + $this->push_context_lines($this->{SECTION_END} + 1, + $this->{NEXT_PATCH_LINE} - 1); + } else { + my $start_context = $this->{NEXT_PATCH_LINE} - $this->{CONTEXT}; + $start_context = $start_context > 0 ? $start_context : 0; + if (($this->{SECTION_END} + $this->{CONTEXT} + 1) < $start_context) { + $this->flush_section(); + $this->_start_section(); + } else { + $this->push_context_lines($this->{SECTION_END} + 1, + $this->{NEXT_PATCH_LINE} - 1); + } + } + push @{$this->{SECTION}{lines}}, $line; + if (substr($line, 0, 1) eq "+") { + $this->{SECTION}{plus_lines}++; + $this->{SECTION}{new_lines}++; + $this->{NEXT_NEW_LINE}++; + } else { + $this->{SECTION_END}++; + $this->{SECTION}{minus_lines}++; + $this->{SECTION}{old_lines}++; + $this->{NEXT_PATCH_LINE}++; + } + } else { + $this->{NEXT_PATCH_LINE}++; + $this->{NEXT_NEW_LINE}++; + } + # If this is context, for now lose it (later we should try and determine if + # we can just use it instead of pulling the file all the time) + } +} + +sub determine_start { + my ($this, $line) = @_; + return 0 if $line < 0; + if ($this->{CONTEXT} eq "file") { + return 1; + } else { + my $start = $line - $this->{CONTEXT}; + $start = $start > 0 ? $start : 1; + return $start; + } +} + +sub _start_section { + my $this = shift; + + # Add the context to the beginning + $this->{SECTION}{old_start} = $this->determine_start($this->{NEXT_PATCH_LINE}); + $this->{SECTION}{new_start} = $this->determine_start($this->{NEXT_NEW_LINE}); + $this->{SECTION}{old_lines} = 0; + $this->{SECTION}{new_lines} = 0; + $this->{SECTION}{minus_lines} = 0; + $this->{SECTION}{plus_lines} = 0; + $this->{SECTION_END} = $this->{SECTION}{old_start} - 1; + $this->push_context_lines($this->{SECTION}{old_start}, + $this->{NEXT_PATCH_LINE} - 1); +} + +sub flush_section { + my $this = shift; + + if ($this->{SECTION}) { + # Add the necessary context to the end + if ($this->{CONTEXT} eq "file") { + $this->push_context_lines($this->{SECTION_END} + 1, "file"); + } else { + $this->push_context_lines($this->{SECTION_END} + 1, + $this->{SECTION_END} + $this->{CONTEXT}); + } + # Send the section and line notifications + $this->{TARGET}->next_section($this->{SECTION}) if $this->{TARGET}; + delete $this->{SECTION}; + $this->{SECTION_END} = 0; + } +} + +sub push_context_lines { + my $this = shift; + # Grab from start to end + my ($start, $end) = @_; + return if $end ne "file" && $start > $end; + + # If it's an added / removed file, don't do anything + return if ! $this->{HAS_CVS_CONTEXT}; + + # Get and open the file if necessary + if (!$this->{FILE}) { + my $olddir = getcwd(); + if (! exists($this->{TMPDIR})) { + $this->{TMPDIR} = File::Temp::tempdir(); + if (! -d $this->{TMPDIR}) { + die "Could not get temporary directory"; + } + } + chdir($this->{TMPDIR}) or die "Could not cd $this->{TMPDIR}"; + if (Bugzilla::PatchReader::CVSClient::cvs_co_rev($this->{CVSROOT}, $this->{REVISION}, $this->{FILENAME})) { + die "Could not check out $this->{FILENAME} r$this->{REVISION} from $this->{CVSROOT}"; + } + open my $fh, $this->{FILENAME} or die "Could not open $this->{FILENAME}"; + $this->{FILE} = $fh; + $this->{NEXT_FILE_LINE} = 1; + trick_taint($olddir); # $olddir comes from getcwd() + chdir($olddir) or die "Could not cd back to $olddir"; + } + + # Read through the file to reach the line we need + die "File read too far!" if $this->{NEXT_FILE_LINE} && $this->{NEXT_FILE_LINE} > $start; + my $fh = $this->{FILE}; + while ($this->{NEXT_FILE_LINE} < $start) { + my $dummy = <$fh>; + $this->{NEXT_FILE_LINE}++; + } + my $i = $start; + for (; $end eq "file" || $i <= $end; $i++) { + my $line = <$fh>; + last if !defined($line); + $line =~ s/\r\n/\n/g; + push @{$this->{SECTION}{lines}}, " $line"; + $this->{NEXT_FILE_LINE}++; + $this->{SECTION}{old_lines}++; + $this->{SECTION}{new_lines}++; + } + $this->{SECTION_END} = $i - 1; +} + +sub trick_taint { + $_[0] =~ /^(.*)$/s; + $_[0] = $1; + return (defined($_[0])); +} + +1; diff --git a/Bugzilla/PatchReader/Base.pm b/Bugzilla/PatchReader/Base.pm new file mode 100644 index 000000000..f2fd69a68 --- /dev/null +++ b/Bugzilla/PatchReader/Base.pm @@ -0,0 +1,23 @@ +package Bugzilla::PatchReader::Base; + +use strict; + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = {}; + bless $this, $class; + + return $this; +} + +sub sends_data_to { + my $this = shift; + if (defined($_[0])) { + $this->{TARGET} = $_[0]; + } else { + return $this->{TARGET}; + } +} + +1 diff --git a/Bugzilla/PatchReader/CVSClient.pm b/Bugzilla/PatchReader/CVSClient.pm new file mode 100644 index 000000000..2f76fc18d --- /dev/null +++ b/Bugzilla/PatchReader/CVSClient.pm @@ -0,0 +1,48 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::PatchReader::CVSClient; + +use strict; + +sub parse_cvsroot { + my $cvsroot = $_[0]; + # Format: :method:[user[:password]@]server[:[port]]/path + if ($cvsroot =~ /^:([^:]*):(.*?)(\/.*)$/) { + my %retval; + $retval{protocol} = $1; + $retval{rootdir} = $3; + my $remote = $2; + if ($remote =~ /^(([^\@:]*)(:([^\@]*))?\@)?([^:]*)(:(.*))?$/) { + $retval{user} = $2; + $retval{password} = $4; + $retval{server} = $5; + $retval{port} = $7; + return %retval; + } + } + + return ( + rootdir => $cvsroot + ); +} + +sub cvs_co { + my ($cvsroot, @files) = @_; + my $cvs = $::cvsbin || "cvs"; + return system($cvs, "-Q", "-d$cvsroot", "co", @files); +} + +sub cvs_co_rev { + my ($cvsroot, $rev, @files) = @_; + my $cvs = $::cvsbin || "cvs"; + return system($cvs, "-Q", "-d$cvsroot", "co", "-r$rev", @files); +} + +1 diff --git a/Bugzilla/PatchReader/DiffPrinter/raw.pm b/Bugzilla/PatchReader/DiffPrinter/raw.pm new file mode 100644 index 000000000..ceb425800 --- /dev/null +++ b/Bugzilla/PatchReader/DiffPrinter/raw.pm @@ -0,0 +1,61 @@ +package Bugzilla::PatchReader::DiffPrinter::raw; + +use strict; + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = {}; + bless $this, $class; + + $this->{OUTFILE} = @_ ? $_[0] : *STDOUT; + my $fh = $this->{OUTFILE}; + + return $this; +} + +sub start_patch { +} + +sub end_patch { +} + +sub start_file { + my $this = shift; + my ($file) = @_; + + my $fh = $this->{OUTFILE}; + if ($file->{rcs_filename}) { + print $fh "Index: $file->{filename}\n"; + print $fh "===================================================================\n"; + print $fh "RCS file: $file->{rcs_filename}\n"; + } + my $old_file = $file->{is_add} ? "/dev/null" : $file->{filename}; + my $old_date = $file->{old_date_str} || ""; + print $fh "--- $old_file\t$old_date"; + print $fh "\t$file->{old_revision}" if $file->{old_revision}; + print $fh "\n"; + my $new_file = $file->{is_remove} ? "/dev/null" : $file->{filename}; + my $new_date = $file->{new_date_str} || ""; + print $fh "+++ $new_file\t$new_date"; + print $fh "\t$file->{new_revision}" if $file->{new_revision}; + print $fh "\n"; +} + +sub end_file { +} + +sub next_section { + my $this = shift; + my ($section) = @_; + + return unless $section->{old_start} || $section->{new_start}; + my $fh = $this->{OUTFILE}; + print $fh "@@ -$section->{old_start},$section->{old_lines} +$section->{new_start},$section->{new_lines} @@ $section->{func_info}\n"; + foreach my $line (@{$section->{lines}}) { + $line =~ s/(\r?\n?)$/\n/; + print $fh $line; + } +} + +1 diff --git a/Bugzilla/PatchReader/DiffPrinter/template.pm b/Bugzilla/PatchReader/DiffPrinter/template.pm new file mode 100644 index 000000000..6545e9336 --- /dev/null +++ b/Bugzilla/PatchReader/DiffPrinter/template.pm @@ -0,0 +1,119 @@ +package Bugzilla::PatchReader::DiffPrinter::template; + +use strict; + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = {}; + bless $this, $class; + + $this->{TEMPLATE_PROCESSOR} = $_[0]; + $this->{HEADER_TEMPLATE} = $_[1]; + $this->{FILE_TEMPLATE} = $_[2]; + $this->{FOOTER_TEMPLATE} = $_[3]; + $this->{ARGS} = $_[4] || {}; + + $this->{ARGS}{file_count} = 0; + return $this; +} + +sub start_patch { + my $this = shift; + $this->{TEMPLATE_PROCESSOR}->process($this->{HEADER_TEMPLATE}, $this->{ARGS}) + || ::ThrowTemplateError($this->{TEMPLATE_PROCESSOR}->error()); +} + +sub end_patch { + my $this = shift; + $this->{TEMPLATE_PROCESSOR}->process($this->{FOOTER_TEMPLATE}, $this->{ARGS}) + || ::ThrowTemplateError($this->{TEMPLATE_PROCESSOR}->error()); +} + +sub start_file { + my $this = shift; + $this->{ARGS}{file_count}++; + $this->{ARGS}{file} = shift; + $this->{ARGS}{file}{plus_lines} = 0; + $this->{ARGS}{file}{minus_lines} = 0; + @{$this->{ARGS}{sections}} = (); +} + +sub end_file { + my $this = shift; + my $file = $this->{ARGS}{file}; + if ($file->{canonical} && $file->{old_revision} && $this->{ARGS}{bonsai_url}) { + $this->{ARGS}{bonsai_prefix} = "$this->{ARGS}{bonsai_url}/cvsblame.cgi?file=$file->{filename}&rev=$file->{old_revision}"; + } + if ($file->{canonical} && $this->{ARGS}{lxr_url}) { + # Cut off the lxr root, if any + my $filename = $file->{filename}; + $filename = substr($filename, length($this->{ARGS}{lxr_root})); + $this->{ARGS}{lxr_prefix} = "$this->{ARGS}{lxr_url}/source/$filename"; + } + + $this->{TEMPLATE_PROCESSOR}->process($this->{FILE_TEMPLATE}, $this->{ARGS}) + || ::ThrowTemplateError($this->{TEMPLATE_PROCESSOR}->error()); + @{$this->{ARGS}{sections}} = (); + delete $this->{ARGS}{file}; +} + +sub next_section { + my $this = shift; + my ($section) = @_; + + $this->{ARGS}{file}{plus_lines} += $section->{plus_lines}; + $this->{ARGS}{file}{minus_lines} += $section->{minus_lines}; + + # Get groups of lines and print them + my $last_line_char = ''; + my $context_lines = []; + my $plus_lines = []; + my $minus_lines = []; + foreach my $line (@{$section->{lines}}) { + $line =~ s/\r?\n?$//; + if ($line =~ /^ /) { + if ($last_line_char ne ' ') { + push @{$section->{groups}}, {context => $context_lines, + plus => $plus_lines, + minus => $minus_lines}; + $context_lines = []; + $plus_lines = []; + $minus_lines = []; + } + $last_line_char = ' '; + push @{$context_lines}, substr($line, 1); + } elsif ($line =~ /^\+/) { + if ($last_line_char eq ' ' || $last_line_char eq '-' && @{$plus_lines}) { + push @{$section->{groups}}, {context => $context_lines, + plus => $plus_lines, + minus => $minus_lines}; + $context_lines = []; + $plus_lines = []; + $minus_lines = []; + $last_line_char = ''; + } + $last_line_char = '+'; + push @{$plus_lines}, substr($line, 1); + } elsif ($line =~ /^-/) { + if ($last_line_char eq '+' && @{$minus_lines}) { + push @{$section->{groups}}, {context => $context_lines, + plus => $plus_lines, + minus => $minus_lines}; + $context_lines = []; + $plus_lines = []; + $minus_lines = []; + $last_line_char = ''; + } + $last_line_char = '-'; + push @{$minus_lines}, substr($line, 1); + } + } + + push @{$section->{groups}}, {context => $context_lines, + plus => $plus_lines, + minus => $minus_lines}; + push @{$this->{ARGS}{sections}}, $section; +} + +1 diff --git a/Bugzilla/PatchReader/FilterPatch.pm b/Bugzilla/PatchReader/FilterPatch.pm new file mode 100644 index 000000000..dfe42e750 --- /dev/null +++ b/Bugzilla/PatchReader/FilterPatch.pm @@ -0,0 +1,43 @@ +package Bugzilla::PatchReader::FilterPatch; + +use strict; + +use Bugzilla::PatchReader::Base; + +@Bugzilla::PatchReader::FilterPatch::ISA = qw(Bugzilla::PatchReader::Base); + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = $class->SUPER::new(); + bless $this, $class; + + return $this; +} + +sub start_patch { + my $this = shift; + $this->{TARGET}->start_patch(@_) if $this->{TARGET}; +} + +sub end_patch { + my $this = shift; + $this->{TARGET}->end_patch(@_) if $this->{TARGET}; +} + +sub start_file { + my $this = shift; + $this->{TARGET}->start_file(@_) if $this->{TARGET}; +} + +sub end_file { + my $this = shift; + $this->{TARGET}->end_file(@_) if $this->{TARGET}; +} + +sub next_section { + my $this = shift; + $this->{TARGET}->next_section(@_) if $this->{TARGET}; +} + +1 diff --git a/Bugzilla/PatchReader/FixPatchRoot.pm b/Bugzilla/PatchReader/FixPatchRoot.pm new file mode 100644 index 000000000..e67fb2796 --- /dev/null +++ b/Bugzilla/PatchReader/FixPatchRoot.pm @@ -0,0 +1,130 @@ +package Bugzilla::PatchReader::FixPatchRoot; + +use Bugzilla::PatchReader::FilterPatch; +use Bugzilla::PatchReader::CVSClient; + +use strict; + +@Bugzilla::PatchReader::FixPatchRoot::ISA = qw(Bugzilla::PatchReader::FilterPatch); + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = $class->SUPER::new(); + bless $this, $class; + + my %parsed = Bugzilla::PatchReader::CVSClient::parse_cvsroot($_[0]); + $this->{REPOSITORY_ROOT} = $parsed{rootdir}; + $this->{REPOSITORY_ROOT} .= "/" if substr($this->{REPOSITORY_ROOT}, -1) ne "/"; + + return $this; +} + +sub diff_root { + my $this = shift; + if (@_) { + $this->{DIFF_ROOT} = $_[0]; + } else { + return $this->{DIFF_ROOT}; + } +} + +sub flush_delayed_commands { + my $this = shift; + return if ! $this->{DELAYED_COMMANDS}; + + my $commands = $this->{DELAYED_COMMANDS}; + delete $this->{DELAYED_COMMANDS}; + $this->{FORCE_COMMANDS} = 1; + foreach my $command_arr (@{$commands}) { + my $command = $command_arr->[0]; + my $arg = $command_arr->[1]; + if ($command eq "start_file") { + $this->start_file($arg); + } elsif ($command eq "end_file") { + $this->end_file($arg); + } elsif ($command eq "section") { + $this->next_section($arg); + } + } +} + +sub end_patch { + my $this = shift; + $this->flush_delayed_commands(); + $this->{TARGET}->end_patch(@_) if $this->{TARGET}; +} + +sub start_file { + my $this = shift; + my ($file) = @_; + # If the file is new, it will not have a filename that fits the repository + # root and therefore needs to be fixed up to have the same root as everyone + # else. At the same time we need to fix DIFF_ROOT too. + if (exists($this->{DIFF_ROOT})) { + # XXX Return error if there are multiple roots in the patch by verifying + # that the DIFF_ROOT is not different from the calculated diff root on this + # filename + + $file->{filename} = $this->{DIFF_ROOT} . $file->{filename}; + + $file->{canonical} = 1; + } elsif ($file->{rcs_filename} && + substr($file->{rcs_filename}, 0, length($this->{REPOSITORY_ROOT})) eq + $this->{REPOSITORY_ROOT}) { + # Since we know the repository we can determine where the user was in the + # repository when they did the diff by chopping off the repository root + # from the rcs filename + $this->{DIFF_ROOT} = substr($file->{rcs_filename}, + length($this->{REPOSITORY_ROOT})); + $this->{DIFF_ROOT} =~ s/,v$//; + # If the RCS file exists in the Attic then we need to correct for + # this, stripping off the '/Attic' suffix in order to reduce the name + # to just the CVS root. + if ($this->{DIFF_ROOT} =~ m/Attic/) { + $this->{DIFF_ROOT} = substr($this->{DIFF_ROOT}, 0, -6); + } + # XXX More error checking--that filename exists and that it is in fact + # part of the rcs filename + $this->{DIFF_ROOT} = substr($this->{DIFF_ROOT}, 0, + -length($file->{filename})); + $this->flush_delayed_commands(); + + $file->{filename} = $this->{DIFF_ROOT} . $file->{filename}; + + $file->{canonical} = 1; + } else { + # DANGER Will Robinson. The first file in the patch is new. We will try + # "delayed command mode" + # + # (if force commands is on we are already in delayed command mode, and sadly + # this means the entire patch was unintelligible to us, so we just output + # whatever the hell was in the patch) + + if (!$this->{FORCE_COMMANDS}) { + push @{$this->{DELAYED_COMMANDS}}, [ "start_file", { %{$file} } ]; + return; + } + } + $this->{TARGET}->start_file($file) if $this->{TARGET}; +} + +sub end_file { + my $this = shift; + if (exists($this->{DELAYED_COMMANDS})) { + push @{$this->{DELAYED_COMMANDS}}, [ "end_file", { %{$_[0]} } ]; + } else { + $this->{TARGET}->end_file(@_) if $this->{TARGET}; + } +} + +sub next_section { + my $this = shift; + if (exists($this->{DELAYED_COMMANDS})) { + push @{$this->{DELAYED_COMMANDS}}, [ "section", { %{$_[0]} } ]; + } else { + $this->{TARGET}->next_section(@_) if $this->{TARGET}; + } +} + +1 diff --git a/Bugzilla/PatchReader/NarrowPatch.pm b/Bugzilla/PatchReader/NarrowPatch.pm new file mode 100644 index 000000000..b6502f2f3 --- /dev/null +++ b/Bugzilla/PatchReader/NarrowPatch.pm @@ -0,0 +1,44 @@ +package Bugzilla::PatchReader::NarrowPatch; + +use Bugzilla::PatchReader::FilterPatch; + +use strict; + +@Bugzilla::PatchReader::NarrowPatch::ISA = qw(Bugzilla::PatchReader::FilterPatch); + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = $class->SUPER::new(); + bless $this, $class; + + $this->{INCLUDE_FILES} = [@_]; + + return $this; +} + +sub start_file { + my $this = shift; + my ($file) = @_; + if (grep { $_ eq substr($file->{filename}, 0, length($_)) } @{$this->{INCLUDE_FILES}}) { + $this->{IS_INCLUDED} = 1; + $this->{TARGET}->start_file(@_) if $this->{TARGET}; + } +} + +sub end_file { + my $this = shift; + if ($this->{IS_INCLUDED}) { + $this->{TARGET}->end_file(@_) if $this->{TARGET}; + $this->{IS_INCLUDED} = 0; + } +} + +sub next_section { + my $this = shift; + if ($this->{IS_INCLUDED}) { + $this->{TARGET}->next_section(@_) if $this->{TARGET}; + } +} + +1 diff --git a/Bugzilla/PatchReader/PatchInfoGrabber.pm b/Bugzilla/PatchReader/PatchInfoGrabber.pm new file mode 100644 index 000000000..8c52931ba --- /dev/null +++ b/Bugzilla/PatchReader/PatchInfoGrabber.pm @@ -0,0 +1,45 @@ +package Bugzilla::PatchReader::PatchInfoGrabber; + +use Bugzilla::PatchReader::FilterPatch; + +use strict; + +@Bugzilla::PatchReader::PatchInfoGrabber::ISA = qw(Bugzilla::PatchReader::FilterPatch); + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = $class->SUPER::new(); + bless $this, $class; + + return $this; +} + +sub patch_info { + my $this = shift; + return $this->{PATCH_INFO}; +} + +sub start_patch { + my $this = shift; + $this->{PATCH_INFO} = {}; + $this->{TARGET}->start_patch(@_) if $this->{TARGET}; +} + +sub start_file { + my $this = shift; + my ($file) = @_; + $this->{PATCH_INFO}{files}{$file->{filename}} = { %{$file} }; + $this->{FILE} = { %{$file} }; + $this->{TARGET}->start_file(@_) if $this->{TARGET}; +} + +sub next_section { + my $this = shift; + my ($section) = @_; + $this->{PATCH_INFO}{files}{$this->{FILE}{filename}}{plus_lines} += $section->{plus_lines}; + $this->{PATCH_INFO}{files}{$this->{FILE}{filename}}{minus_lines} += $section->{minus_lines}; + $this->{TARGET}->next_section(@_) if $this->{TARGET}; +} + +1 diff --git a/Bugzilla/PatchReader/Raw.pm b/Bugzilla/PatchReader/Raw.pm new file mode 100644 index 000000000..b58ed3a2d --- /dev/null +++ b/Bugzilla/PatchReader/Raw.pm @@ -0,0 +1,268 @@ +package Bugzilla::PatchReader::Raw; + +# +# USAGE: +# use PatchReader::Raw; +# my $parser = new PatchReader::Raw(); +# $parser->sends_data_to($my_target); +# $parser->start_lines(); +# open FILE, "mypatch.patch"; +# while (<FILE>) { +# $parser->next_line($_); +# } +# $parser->end_lines(); +# + +use strict; + +use Bugzilla::PatchReader::Base; + +@Bugzilla::PatchReader::Raw::ISA = qw(Bugzilla::PatchReader::Base); + +sub new { + my $class = shift; + $class = ref($class) || $class; + my $this = $class->SUPER::new(); + bless $this, $class; + + return $this; +} + +# We send these notifications: +# start_patch({ patchname }) +# start_file({ filename, rcs_filename, old_revision, old_date_str, new_revision, new_date_str, is_add, is_remove }) +# next_section({ old_start, new_start, old_lines, new_lines, @lines }) +# end_patch +# end_file +sub next_line { + my $this = shift; + my ($line) = @_; + + return if $line =~ /^\?/; + + # patch header parsing + if ($line =~ /^---\s*([\S ]+)\s*\t([^\t\r\n]*)\s*(\S*)/) { + $this->_maybe_end_file(); + + if ($1 eq "/dev/null") { + $this->{FILE_STATE}{is_add} = 1; + } else { + $this->{FILE_STATE}{filename} = $1; + } + $this->{FILE_STATE}{old_date_str} = $2; + $this->{FILE_STATE}{old_revision} = $3 if $3; + + $this->{IN_HEADER} = 1; + + } elsif ($line =~ /^\+\+\+\s*([\S ]+)\s*\t([^\t\r\n]*)(\S*)/) { + if ($1 eq "/dev/null") { + $this->{FILE_STATE}{is_remove} = 1; + } + $this->{FILE_STATE}{new_date_str} = $2; + $this->{FILE_STATE}{new_revision} = $3 if $3; + + $this->{IN_HEADER} = 1; + + } elsif ($line =~ /^RCS file: ([\S ]+)/) { + $this->{FILE_STATE}{rcs_filename} = $1; + + $this->{IN_HEADER} = 1; + + } elsif ($line =~ /^retrieving revision (\S+)/) { + $this->{FILE_STATE}{old_revision} = $1; + + $this->{IN_HEADER} = 1; + + } elsif ($line =~ /^Index:\s*([\S ]+)/) { + $this->_maybe_end_file(); + + $this->{FILE_STATE}{filename} = $1; + + $this->{IN_HEADER} = 1; + + } elsif ($line =~ /^diff\s*(-\S+\s*)*(\S+)\s*(\S*)/ && $3) { + # Simple diff <dir> <dir> + $this->_maybe_end_file(); + $this->{FILE_STATE}{filename} = $2; + + $this->{IN_HEADER} = 1; + + # section parsing + } elsif ($line =~ /^@@\s*-(\d+),?(\d*)\s*\+(\d+),?(\d*)\s*(?:@@\s*(.*))?/) { + $this->{IN_HEADER} = 0; + + $this->_maybe_start_file(); + $this->_maybe_end_section(); + $2 = 0 if !defined($2); + $4 = 0 if !defined($4); + $this->{SECTION_STATE} = { old_start => $1, old_lines => $2, + new_start => $3, new_lines => $4, + func_info => $5, + minus_lines => 0, plus_lines => 0, + }; + + } elsif ($line =~ /^(\d+),?(\d*)([acd])(\d+),?(\d*)/) { + # Non-universal diff. Calculate as though it were universal. + $this->{IN_HEADER} = 0; + + $this->_maybe_start_file(); + $this->_maybe_end_section(); + + my $old_start; + my $old_lines; + my $new_start; + my $new_lines; + if ($3 eq 'a') { + # 'a' has the old number one off from diff -u ("insert after this line" + # vs. "insert at this line") + $old_start = $1 + 1; + $old_lines = 0; + } else { + $old_start = $1; + $old_lines = $2 ? ($2 - $1 + 1) : 1; + } + if ($3 eq 'd') { + # 'd' has the new number one off from diff -u ("delete after this line" + # vs. "delete at this line") + $new_start = $4 + 1; + $new_lines = 0; + } else { + $new_start = $4; + $new_lines = $5 ? ($5 - $4 + 1) : 1; + } + + $this->{SECTION_STATE} = { old_start => $old_start, old_lines => $old_lines, + new_start => $new_start, new_lines => $new_lines, + minus_lines => 0, plus_lines => 0 + }; + } + + # line parsing (only when inside a section) + return if $this->{IN_HEADER}; + if ($line =~ /^ /) { + push @{$this->{SECTION_STATE}{lines}}, $line; + } elsif ($line =~ /^-/) { + $this->{SECTION_STATE}{minus_lines}++; + push @{$this->{SECTION_STATE}{lines}}, $line; + } elsif ($line =~ /^\+/) { + $this->{SECTION_STATE}{plus_lines}++; + push @{$this->{SECTION_STATE}{lines}}, $line; + } elsif ($line =~ /^< /) { + $this->{SECTION_STATE}{minus_lines}++; + push @{$this->{SECTION_STATE}{lines}}, "-" . substr($line, 2); + } elsif ($line =~ /^> /) { + $this->{SECTION_STATE}{plus_lines}++; + push @{$this->{SECTION_STATE}{lines}}, "+" . substr($line, 2); + } +} + +sub start_lines { + my $this = shift; + die "No target specified: call sends_data_to!" if !$this->{TARGET}; + delete $this->{FILE_STARTED}; + delete $this->{FILE_STATE}; + delete $this->{SECTION_STATE}; + $this->{FILE_NEVER_STARTED} = 1; + + $this->{TARGET}->start_patch(@_); +} + +sub end_lines { + my $this = shift; + $this->_maybe_end_file(); + $this->{TARGET}->end_patch(@_); +} + +sub _init_state { + my $this = shift; + $this->{SECTION_STATE}{minus_lines} ||= 0; + $this->{SECTION_STATE}{plus_lines} ||= 0; +} + +sub _maybe_start_file { + my $this = shift; + $this->_init_state(); + if (exists($this->{FILE_STATE}) && !$this->{FILE_STARTED} || + $this->{FILE_NEVER_STARTED}) { + $this->_start_file(); + } +} + +sub _maybe_end_file { + my $this = shift; + $this->_init_state(); + return if $this->{IN_HEADER}; + + $this->_maybe_end_section(); + if (exists($this->{FILE_STATE})) { + # Handle empty patch sections (if the file has not been started and we're + # already trying to end it, start it first!) + if (!$this->{FILE_STARTED}) { + $this->_start_file(); + } + + # Send end notification and set state + $this->{TARGET}->end_file($this->{FILE_STATE}); + delete $this->{FILE_STATE}; + delete $this->{FILE_STARTED}; + } +} + +sub _start_file { + my $this = shift; + + # Send start notification and set state + if (!$this->{FILE_STATE}) { + $this->{FILE_STATE} = { filename => "file_not_specified_in_diff" }; + } + + # Send start notification and set state + $this->{TARGET}->start_file($this->{FILE_STATE}); + $this->{FILE_STARTED} = 1; + delete $this->{FILE_NEVER_STARTED}; +} + +sub _maybe_end_section { + my $this = shift; + if (exists($this->{SECTION_STATE})) { + $this->{TARGET}->next_section($this->{SECTION_STATE}); + delete $this->{SECTION_STATE}; + } +} + +sub iterate_file { + my $this = shift; + my ($filename) = @_; + + open FILE, $filename or die "Could not open $filename: $!"; + $this->start_lines($filename); + while (<FILE>) { + $this->next_line($_); + } + $this->end_lines($filename); + close FILE; +} + +sub iterate_fh { + my $this = shift; + my ($fh, $filename) = @_; + + $this->start_lines($filename); + while (<$fh>) { + $this->next_line($_); + } + $this->end_lines($filename); +} + +sub iterate_string { + my $this = shift; + my ($id, $data) = @_; + + $this->start_lines($id); + while ($data =~ /([^\n]*(\n|$))/g) { + $this->next_line($1); + } + $this->end_lines($id); +} + +1 diff --git a/Bugzilla/Product.pm b/Bugzilla/Product.pm index a0079a033..452ae90fc 100644 --- a/Bugzilla/Product.pm +++ b/Bugzilla/Product.pm @@ -114,7 +114,7 @@ sub create { # for each product in the list, particularly with hundreds or thousands # of products. sub preload { - my ($products, $preload_flagtypes) = @_; + my ($products, $preload_flagtypes, $flagtypes_params) = @_; my %prods = map { $_->id => $_ } @$products; my @prod_ids = keys %prods; return unless @prod_ids; @@ -132,7 +132,7 @@ sub preload { } } if ($preload_flagtypes) { - $_->flag_types foreach @$products; + $_->flag_types($flagtypes_params) foreach @$products; } } @@ -779,7 +779,8 @@ sub user_has_access { } sub flag_types { - my $self = shift; + my ($self, $params) = @_; + $params ||= {}; return $self->{'flag_types'} if defined $self->{'flag_types'}; @@ -787,7 +788,7 @@ sub flag_types { my $cache = Bugzilla->request_cache->{flag_types_per_product} ||= {}; $self->{flag_types} = {}; my $prod_id = $self->id; - my $flagtypes = Bugzilla::FlagType::match({ product_id => $prod_id }); + my $flagtypes = Bugzilla::FlagType::match({ product_id => $prod_id, %$params }); foreach my $type ('bug', 'attachment') { my @flags = grep { $_->target_type eq $type } @$flagtypes; @@ -816,8 +817,8 @@ sub flag_types { sub classification { my $self = shift; - $self->{'classification'} ||= - new Bugzilla::Classification($self->classification_id); + $self->{'classification'} ||= + new Bugzilla::Classification({ id => $self->classification_id, cache => 1 }); return $self->{'classification'}; } diff --git a/Bugzilla/Search.pm b/Bugzilla/Search.pm index 8e419c0ee..0c52553bb 100644 --- a/Bugzilla/Search.pm +++ b/Bugzilla/Search.pm @@ -38,7 +38,6 @@ use base qw(Exporter); @Bugzilla::Search::EXPORT = qw( IsValidQueryType split_order_term - translate_old_column ); use Bugzilla::Error; @@ -48,6 +47,7 @@ use Bugzilla::Group; use Bugzilla::User; use Bugzilla::Field; use Bugzilla::Search::Clause; +use Bugzilla::Search::ClauseGroup; use Bugzilla::Search::Condition qw(condition); use Bugzilla::Status; use Bugzilla::Keyword; @@ -56,9 +56,10 @@ use Data::Dumper; use Date::Format; use Date::Parse; use Scalar::Util qw(blessed); -use List::MoreUtils qw(all part uniq); +use List::MoreUtils qw(all firstidx part uniq); use POSIX qw(INT_MAX); use Storable qw(dclone); +use Time::HiRes qw(gettimeofday tv_interval); # Description Of Boolean Charts # ----------------------------- @@ -130,8 +131,14 @@ use Storable qw(dclone); # Constants # ############# +# BMO - product aliases for searching +use constant PRODUCT_ALIASES => { + 'Boot2Gecko' => 'Firefox OS', +}; + # When doing searches, NULL datetimes are treated as this date. use constant EMPTY_DATETIME => '1970-01-01 00:00:00'; +use constant EMPTY_DATE => '1970-01-01'; # This is the regex for real numbers from Regexp::Common, modified to be # more readable. @@ -182,6 +189,8 @@ use constant OPERATORS => { changedfrom => \&_changedfrom_changedto, changedto => \&_changedfrom_changedto, changedby => \&_changedby, + isempty => \&_isempty, + isnotempty => \&_isnotempty, }; # Some operators are really just standard SQL operators, and are @@ -208,6 +217,8 @@ use constant OPERATOR_REVERSE => { lessthaneq => 'greaterthan', greaterthan => 'lessthaneq', greaterthaneq => 'lessthan', + isempty => 'isnotempty', + isnotempty => 'isempty', # The following don't currently have reversals: # casesubstring, anyexact, allwords, allwordssubstr }; @@ -223,6 +234,12 @@ use constant NON_NUMERIC_OPERATORS => qw( notregexp ); +# These operators ignore the entered value +use constant NO_VALUE_OPERATORS => qw( + isempty + isnotempty +); + use constant MULTI_SELECT_OVERRIDE => { notequals => \&_multiselect_negative, notregexp => \&_multiselect_negative, @@ -336,6 +353,7 @@ use constant OPERATOR_FIELD_OVERRIDE => { FIELD_TYPE_FREETEXT, { _non_changed => \&_nullable }, FIELD_TYPE_BUG_ID, { _non_changed => \&_nullable_int }, FIELD_TYPE_DATETIME, { _non_changed => \&_nullable_datetime }, + FIELD_TYPE_DATE, { _non_changed => \&_nullable_date }, FIELD_TYPE_TEXTAREA, { _non_changed => \&_nullable }, FIELD_TYPE_MULTI_SELECT, MULTI_SELECT_OVERRIDE, FIELD_TYPE_BUG_URLS, MULTI_SELECT_OVERRIDE, @@ -343,18 +361,29 @@ use constant OPERATOR_FIELD_OVERRIDE => { # These are fields where special action is taken depending on the # *value* passed in to the chart, sometimes. -use constant SPECIAL_PARSING => { - # Pronoun Fields (Ones that can accept %user%, etc.) - assigned_to => \&_contact_pronoun, - cc => \&_cc_pronoun, - commenter => \&_commenter_pronoun, - qa_contact => \&_contact_pronoun, - reporter => \&_contact_pronoun, - - # Date Fields that accept the 1d, 1w, 1m, 1y, etc. format. - creation_ts => \&_timestamp_translate, - deadline => \&_timestamp_translate, - delta_ts => \&_timestamp_translate, +# This is a sub because custom fields are dynamic +sub SPECIAL_PARSING { + my $map = { + # Pronoun Fields (Ones that can accept %user%, etc.) + assigned_to => \&_contact_pronoun, + cc => \&_cc_pronoun, + commenter => \&_commenter_pronoun, + qa_contact => \&_contact_pronoun, + reporter => \&_contact_pronoun, + + # Date Fields that accept the 1d, 1w, 1m, 1y, etc. format. + creation_ts => \&_datetime_translate, + deadline => \&_date_translate, + delta_ts => \&_datetime_translate, + }; + foreach my $field (Bugzilla->active_custom_fields) { + if ($field->type == FIELD_TYPE_DATETIME) { + $map->{$field->name} = \&_datetime_translate; + } elsif ($field->type == FIELD_TYPE_DATE) { + $map->{$field->name} = \&_date_translate; + } + } + return $map; }; # Information about fields that represent "users", used by _user_nonchanged. @@ -485,6 +514,14 @@ use constant COLUMN_JOINS => { to => 'id', }, }, + blocked => { + table => 'dependencies', + to => 'dependson', + }, + dependson => { + table => 'dependencies', + to => 'blocked', + }, 'longdescs.count' => { table => 'longdescs', join => 'INNER', @@ -552,15 +589,18 @@ sub COLUMNS { . $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status')), 'keywords' => $dbh->sql_group_concat('DISTINCT map_keyworddefs.name'), + + blocked => $dbh->sql_group_concat('DISTINCT map_blocked.blocked'), + dependson => $dbh->sql_group_concat('DISTINCT map_dependson.dependson'), 'longdescs.count' => 'COUNT(DISTINCT map_longdescs_count.comment_id)', ); # Backward-compatibility for old field names. Goes new_name => old_name. - # These are here and not in translate_old_column because the rest of the + # These are here and not in _translate_old_column because the rest of the # code actually still uses the old names, while the fielddefs table uses # the new names (which is not the case for the fields handled by - # translate_old_column). + # _translate_old_column). my %old_names = ( creation_ts => 'opendate', delta_ts => 'changeddate', @@ -647,7 +687,9 @@ sub REPORT_COLUMNS { # is here because it *always* goes into the GROUP BY as the first item, # so it should be skipped when determining extra GROUP BY columns. use constant GROUP_BY_SKIP => qw( + blocked bug_id + dependson flagtypes.name keywords longdescs.count @@ -688,7 +730,128 @@ sub new { # Public Accessors # #################### -sub sql { +sub data { + my $self = shift; + return $self->{data} if $self->{data}; + my $dbh = Bugzilla->dbh; + + # If all fields belong to the 'bugs' table, there is no need to split + # the original query into two pieces. Else we override the 'fields' + # argument to first get bug IDs based on the search criteria defined + # by the caller, and the desired fields are collected in the 2nd query. + my @orig_fields = $self->_input_columns; + my $all_in_bugs_table = 1; + foreach my $field (@orig_fields) { + next if $self->COLUMNS->{$field}->{name} =~ /^bugs\.\w+$/; + $self->{fields} = ['bug_id']; + $all_in_bugs_table = 0; + last; + } + + # BMO - to avoid massive amounts of joins, if we're selecting a lot of + # tracking flags, replace them with placeholders. the values will be + # retrieved later and injected into the result. + my %tf_map = map { $_ => 1 } Bugzilla::Extension::TrackingFlags::Flag->get_all_names(); + my @tf_selected = grep { exists $tf_map{$_} } @orig_fields; + # mysql has a limit of 61 joins, and we want to avoid massive amounts of joins + # 30 ensures we won't hit the limit, nor generate too many joins + if (scalar @tf_selected > 30) { + foreach my $column (@tf_selected) { + $self->COLUMNS->{$column}->{name} = "'---'"; + } + $self->{tracking_flags} = \@tf_selected; + } + else { + $self->{tracking_flags} = []; + } + + my $start_time = [gettimeofday()]; + my $sql = $self->_sql; + # Do we just want bug IDs to pass to the 2nd query or all the data immediately? + my $func = $all_in_bugs_table ? 'selectall_arrayref' : 'selectcol_arrayref'; + my $bug_ids = $dbh->$func($sql); + my @extra_data = ({sql => $sql, time => tv_interval($start_time)}); + # Restore the original 'fields' argument, just in case. + $self->{fields} = \@orig_fields unless $all_in_bugs_table; + + # BMO if the caller only wants the count, that's all we need to return + return $bug_ids->[0]->[0] if $self->_params->{count_only}; + + # If there are no bugs found, or all fields are in the 'bugs' table, + # there is no need for another query. + if (!scalar @$bug_ids || $all_in_bugs_table) { + $self->{data} = $bug_ids; + return wantarray ? ($self->{data}, \@extra_data) : $self->{data}; + } + + # Make sure the bug_id will be returned. If not, append it to the list. + my $pos = firstidx { $_ eq 'bug_id' } @orig_fields; + if ($pos < 0) { + push(@orig_fields, 'bug_id'); + $pos = $#orig_fields; + } + + # Now create a query with the buglist above as the single criteria + # and the fields that the caller wants. No need to redo security checks; + # the list has already been validated above. + my $search = $self->new('fields' => \@orig_fields, + 'params' => {bug_id => $bug_ids, bug_id_type => 'anyexact'}, + 'sharer' => $self->_sharer_id, + 'user' => $self->_user, + 'allow_unlimited' => 1, + '_no_security_check' => 1); + + $start_time = [gettimeofday()]; + $sql = $search->_sql; + my $unsorted_data = $dbh->selectall_arrayref($sql); + push(@extra_data, {sql => $sql, time => tv_interval($start_time)}); + # Let's sort the data. We didn't do it in the query itself because + # we already know in which order to sort bugs thanks to the first query, + # and this avoids additional table joins in the SQL query. + my %data = map { $_->[$pos] => $_ } @$unsorted_data; + $self->{data} = [map { $data{$_} } @$bug_ids]; + + # BMO - get tracking flags values, and insert into result + if (@{ $self->{tracking_flags} }) { + # read values + my $values; + $sql = " + SELECT bugs.bug_id, tracking_flags.name, tracking_flags_bugs.value + FROM bugs + LEFT JOIN tracking_flags_bugs ON tracking_flags_bugs.bug_id = bugs.bug_id + LEFT JOIN tracking_flags ON tracking_flags.id = tracking_flags_bugs.tracking_flag_id + WHERE " . $dbh->sql_in('bugs.bug_id', $bug_ids); + $start_time = [gettimeofday()]; + my $rows = $dbh->selectall_arrayref($sql); + push(@extra_data, {sql => $sql, time => tv_interval($start_time)}); + foreach my $row (@$rows) { + $values->{$row->[0]}{$row->[1]} = $row->[2] if defined($row->[2]); + } + + # find the columns of the tracking flags + my %tf_pos; + for (my $i = 0; $i <= $#orig_fields; $i++) { + if (grep { $_ eq $orig_fields[$i] } @{ $self->{tracking_flags} }) { + $tf_pos{$orig_fields[$i]} = $i; + } + } + + # replace the placeholder value with the field's value + foreach my $row (@{ $self->{data} }) { + my $bug_id = $row->[$pos]; + next unless exists $values->{$bug_id}; + foreach my $field (keys %{ $values->{$bug_id} }) { + if (exists $tf_pos{$field}) { + $row->[$tf_pos{$field}] = $values->{$bug_id}{$field}; + } + } + } + } + + return wantarray ? ($self->{data}, \@extra_data) : $self->{data}; +} + +sub _sql { my ($self) = @_; return $self->{sql} if $self->{sql}; my $dbh = Bugzilla->dbh; @@ -710,7 +873,15 @@ sub sql { ? "\nORDER BY " . join(', ', $self->_sql_order_by) : ''; my $limit = $self->_sql_limit; $limit = "\n$limit" if $limit; - + + # BMO allow fetching just the number of matching bugs + if ($self->_params->{count_only}) { + $select = 'COUNT(*) AS count'; + $group_by = ''; + $order_by = ''; + $limit = ''; + } + my $query = <<END; SELECT $select FROM $from @@ -725,12 +896,30 @@ sub search_description { my ($self, $params) = @_; my $desc = $self->{'search_description'} ||= []; if ($params) { + + # BMO - product aliases + # display the new product name on the search results name to avoid a + # disparity between the search summary and the results. + if ($params->{field} eq 'product') { + my $aliased; + my @values = split(/,/, $params->{value}); + foreach my $value (@values) { + if (exists PRODUCT_ALIASES->{lc($value)}) { + $value = PRODUCT_ALIASES->{lc($value)}; + $aliased = 1; + } + } + if ($aliased) { + $params->{value} = join(',', @values); + } + } + push(@$desc, $params); } # Make sure that the description has actually been generated if # people are asking for the whole thing. else { - $self->sql; + $self->_sql; } return $self->{'search_description'}; } @@ -754,6 +943,21 @@ sub boolean_charts_to_custom_search { } } +sub invalid_order_columns { + my ($self) = @_; + my @invalid_columns; + foreach my $order ($self->_input_order) { + next if defined $self->_validate_order_column($order); + push(@invalid_columns, $order); + } + return \@invalid_columns; +} + +sub order { + my ($self) = @_; + return $self->_valid_order; +} + ###################### # Internal Accessors # ###################### @@ -819,7 +1023,7 @@ sub _extra_columns { my ($self) = @_; # Everything that's going to be in the ORDER BY must also be # in the SELECT. - push(@{ $self->{extra_columns} }, $self->_input_order_columns); + push(@{ $self->{extra_columns} }, $self->_valid_order_columns); return @{ $self->{extra_columns} }; } @@ -889,10 +1093,32 @@ sub _sql_select { # The "order" that was requested by the consumer, exactly as it was # requested. sub _input_order { @{ $_[0]->{'order'} || [] } } -# The input order with just the column names, and no ASC or DESC. -sub _input_order_columns { +# Requested order with invalid values removed and old names translated +sub _valid_order { my ($self) = @_; - return map { (split_order_term($_))[0] } $self->_input_order; + return map { ($self->_validate_order_column($_)) } $self->_input_order; +} +# The valid order with just the column names, and no ASC or DESC. +sub _valid_order_columns { + my ($self) = @_; + return map { (split_order_term($_))[0] } $self->_valid_order; +} + +sub _validate_order_column { + my ($self, $order_item) = @_; + + # Translate old column names + my ($field, $direction) = split_order_term($order_item); + $field = $self->_translate_old_column($field); + + # Only accept valid columns + return if (!exists $self->COLUMNS->{$field}); + + # Relevance column can be used only with one or more fulltext searches + return if ($field eq 'relevance' && !$self->COLUMNS->{$field}->{name}); + + $direction = " $direction" if $direction; + return "$field$direction"; } # A hashref that describes all the special stuff that has to be done @@ -924,7 +1150,7 @@ sub _sql_order_by { my ($self) = @_; if (!$self->{sql_order_by}) { my @order_by = map { $self->_translate_order_by_column($_) } - $self->_input_order; + $self->_valid_order; $self->{sql_order_by} = \@order_by; } return @{ $self->{sql_order_by} }; @@ -1069,7 +1295,7 @@ sub _select_order_joins { my @column_join = $self->_column_join($field); push(@joins, @column_join); } - foreach my $field ($self->_input_order_columns) { + foreach my $field ($self->_valid_order_columns) { my $join_info = $self->_special_order->{$field}->{join}; if ($join_info) { # Don't let callers modify SPECIAL_ORDER. @@ -1088,6 +1314,7 @@ sub _standard_joins { my ($self) = @_; my $user = $self->_user; my @joins; + return () if $self->{_no_security_check}; my $security_join = { table => 'bug_group_map', @@ -1126,8 +1353,8 @@ sub _translate_join { die "join with no table: " . Dumper($join_info) if !$join_info->{table}; die "join with no 'as': " . Dumper($join_info) if !$join_info->{as}; - - my $from_table = "bugs"; + + my $from_table = $join_info->{bugs_table} || "bugs"; my $from = $join_info->{from} || "bug_id"; if ($from =~ /^(\w+)\.(\w+)$/) { ($from_table, $from) = ($1, $2); @@ -1164,6 +1391,7 @@ sub _translate_join { # group security. sub _standard_where { my ($self) = @_; + return ('1=1') if $self->{_no_security_check}; # If replication lags badly between the shadow db and the main DB, # it's possible for bugs to show up in searches before their group # controls are properly set. To prevent this, when initially creating @@ -1225,7 +1453,7 @@ sub _sql_group_by { # And all items from ORDER BY must be in the GROUP BY. The above loop # doesn't catch items that were put into the ORDER BY from SPECIAL_ORDER. - foreach my $column ($self->_input_order_columns) { + foreach my $column ($self->_valid_order_columns) { my $special_order = $self->_special_order->{$column}->{order}; next if !$special_order; push(@extra_group_by, @$special_order); @@ -1525,7 +1753,7 @@ sub _charts_to_conditions { my $clause = $self->_charts; my @joins; $clause->walk_conditions(sub { - my ($condition) = @_; + my ($clause, $condition) = @_; return if !$condition->translated; push(@joins, @{ $condition->translated->{joins} }); }); @@ -1545,7 +1773,7 @@ sub _params_to_data_structure { my ($self) = @_; # First we get the "special" charts, representing all the normal - # field son the search page. This may modify _params, so it needs to + # fields on the search page. This may modify _params, so it needs to # happen first. my $clause = $self->_special_charts; @@ -1554,7 +1782,7 @@ sub _params_to_data_structure { # And then process the modern "custom search" format. $clause->add( $self->_custom_search ); - + return $clause; } @@ -1585,7 +1813,9 @@ sub _boolean_charts { my $identifier = "$chart_id-$and_id-$or_id"; my $field = $params->{"field$identifier"}; my $operator = $params->{"type$identifier"}; - my $value = $params->{"value$identifier"}; + my $value = $params->{"value$identifier"}; + # no-value operators ignore the value, however a value needs to be set + $value = ' ' if $operator && grep { $_ eq $operator } NO_VALUE_OPERATORS; $or_clause->add($field, $operator, $value); } $and_clause->add($or_clause); @@ -1601,13 +1831,18 @@ sub _custom_search { my ($self) = @_; my $params = $self->_params; - my $current_clause = new Bugzilla::Search::Clause($params->{j_top}); + my $joiner = $params->{j_top} || ''; + my $current_clause = $joiner eq 'AND_G' + ? new Bugzilla::Search::ClauseGroup() + : new Bugzilla::Search::Clause($joiner); my @clause_stack; foreach my $id ($self->_field_ids) { my $field = $params->{"f$id"}; if ($field eq 'OP') { - my $joiner = $params->{"j$id"}; - my $new_clause = new Bugzilla::Search::Clause($joiner); + my $joiner = $params->{"j$id"} || ''; + my $new_clause = $joiner eq 'AND_G' + ? new Bugzilla::Search::ClauseGroup() + : new Bugzilla::Search::Clause($joiner); $new_clause->negate($params->{"n$id"}); $current_clause->add($new_clause); push(@clause_stack, $current_clause); @@ -1623,6 +1858,8 @@ sub _custom_search { my $operator = $params->{"o$id"}; my $value = $params->{"v$id"}; + # no-value operators ignore the value, however a value needs to be set + $value = ' ' if $operator && grep { $_ eq $operator } NO_VALUE_OPERATORS; my $condition = condition($field, $operator, $value); $condition->negate($params->{"n$id"}); $current_clause->add($condition); @@ -1646,14 +1883,12 @@ sub _field_ids { } sub _handle_chart { - my ($self, $chart_id, $condition) = @_; + my ($self, $chart_id, $clause, $condition) = @_; my $dbh = Bugzilla->dbh; my $params = $self->_params; my ($field, $operator, $value) = $condition->fov; - - $field = FIELD_MAP->{$field} || $field; - return if (!defined $field or !defined $operator or !defined $value); + $field = FIELD_MAP->{$field} || $field; my $string_value; if (ref $value eq 'ARRAY') { @@ -1684,16 +1919,20 @@ sub _handle_chart { # on multiple values, like anyexact. my %search_args = ( - chart_id => $chart_id, - sequence => $chart_id, - field => $field, - full_field => $full_field, - operator => $operator, - value => $string_value, - all_values => $value, - joins => [], - condition => $condition, + chart_id => $chart_id, + sequence => $chart_id, + field => $field, + full_field => $full_field, + operator => $operator, + value => $string_value, + all_values => $value, + joins => [], + bugs_table => 'bugs', + table_suffix => '', + condition => $condition, ); + $clause->update_search_args(\%search_args); + $search_args{quoted} = $self->_quote_unless_numeric(\%search_args); # This should add a "term" selement to %search_args. $self->do_search_function(\%search_args); @@ -1709,7 +1948,12 @@ sub _handle_chart { field => $field, type => $operator, value => $string_value, term => $search_args{term}, }); - + + foreach my $join (@{ $search_args{joins} }) { + $join->{bugs_table} = $search_args{bugs_table}; + $join->{table_suffix} = $search_args{table_suffix}; + } + $condition->translated(\%search_args); } @@ -1830,8 +2074,9 @@ sub _get_column_joins { return $cache->{column_joins} if defined $cache->{column_joins}; my %column_joins = %{ COLUMN_JOINS() }; + # BMO - add search object to hook Bugzilla::Hook::process('buglist_column_joins', - { column_joins => \%column_joins }); + { column_joins => \%column_joins, search => $self }); $cache->{column_joins} = \%column_joins; return $cache->{column_joins}; @@ -1951,22 +2196,29 @@ sub _word_terms { ##################################### sub _timestamp_translate { - my ($self, $args) = @_; + my ($self, $ignore_time, $args) = @_; my $value = $args->{value}; my $dbh = Bugzilla->dbh; return if $value !~ /^(?:[\+\-]?\d+[hdwmy]s?|now)$/i; - # By default, the time is appended to the date, which we don't want - # for deadlines. $value = SqlifyDate($value); - if ($args->{field} eq 'deadline') { + # By default, the time is appended to the date, which we don't always want. + if ($ignore_time) { ($value) = split(/\s/, $value); } $args->{value} = $value; $args->{quoted} = $dbh->quote($value); } +sub _datetime_translate { + return shift->_timestamp_translate(0, @_); +} + +sub _date_translate { + return shift->_timestamp_translate(1, @_); +} + sub SqlifyDate { my ($str) = @_; my $fmt = "%Y-%m-%d %H:%M:%S"; @@ -2259,7 +2511,7 @@ sub _user_nonchanged { # For negative operators, the system we're using here # only works properly if we reverse the operator and check IS NULL # in the WHERE. - my $is_negative = $operator =~ /^no/ ? 1 : 0; + my $is_negative = $operator =~ /^(?:no|isempty)/ ? 1 : 0; if ($is_negative) { $args->{operator} = $self->_reverse_operator($operator); } @@ -2339,8 +2591,13 @@ sub _long_desc_changedbefore_after { sub _long_desc_nonchanged { my ($self, $args) = @_; - my ($chart_id, $operator, $value, $joins) = - @$args{qw(chart_id operator value joins)}; + my ($chart_id, $operator, $value, $joins, $bugs_table) = + @$args{qw(chart_id operator value joins bugs_table)}; + + if ($operator =~ /^is(not)?empty$/) { + $args->{term} = $self->_multiselect_isempty($args, $operator eq 'isnotempty'); + return; + } my $dbh = Bugzilla->dbh; my $table = "longdescs_$chart_id"; @@ -2354,6 +2611,7 @@ sub _long_desc_nonchanged { all_values => $value, quoted => $dbh->quote($value), joins => [], + bugs_table => $bugs_table, }; $self->_do_operator_function($join_args); @@ -2501,7 +2759,28 @@ sub _component_nonchanged { sub _product_nonchanged { my ($self, $args) = @_; - + + # BMO - product aliases + # swap out old product names for new ones + if (ref($args->{all_values})) { + my $aliased; + foreach my $value (@{ $args->{all_values} }) { + if (exists PRODUCT_ALIASES->{lc($value)}) { + $value = PRODUCT_ALIASES->{lc($value)}; + $aliased = 1; + } + } + if ($aliased) { + $args->{value} = join(',', @{ $args->{all_values} }); + $args->{quoted} = Bugzilla->dbh->quote($args->{value}); + } + } + elsif (exists PRODUCT_ALIASES->{lc($args->{value})}) { + $args->{value} = PRODUCT_ALIASES->{lc($args->{value})}; + $args->{all_values} = $args->{value}; + $args->{quoted} = Bugzilla->dbh->quote($args->{value}); + } + # Generate the restriction condition $args->{full_field} = "products.name"; $self->_do_operator_function($args); @@ -2544,6 +2823,13 @@ sub _nullable_datetime { $args->{full_field} = "COALESCE($field, $empty)"; } +sub _nullable_date { + my ($self, $args) = @_; + my $field = $args->{full_field}; + my $empty = Bugzilla->dbh->quote(EMPTY_DATE); + $args->{full_field} = "COALESCE($field, $empty)"; +} + sub _deadline { my ($self, $args) = @_; my $field = $args->{full_field}; @@ -2586,7 +2872,7 @@ sub _owner_idle_time_greater_less { "$ld_table.who IS NULL AND $act_table.who IS NULL"; } else { $args->{term} = - "$ld_table.who IS NOT NULL OR $act_table.who IS NOT NULL"; + "($ld_table.who IS NOT NULL OR $act_table.who IS NOT NULL)"; } } @@ -2630,8 +2916,14 @@ sub _multiselect_multiple { sub _flagtypes_nonchanged { my ($self, $args) = @_; - my ($chart_id, $operator, $value, $joins, $condition) = - @$args{qw(chart_id operator value joins condition)}; + my ($chart_id, $operator, $value, $joins, $bugs_table, $condition) = + @$args{qw(chart_id operator value joins bugs_table condition)}; + + if ($operator =~ /^is(not)?empty$/) { + $args->{term} = $self->_multiselect_isempty($args, $operator eq 'isnotempty'); + return; + } + my $dbh = Bugzilla->dbh; # For 'not' operators, we need to negate the whole term. @@ -2654,6 +2946,7 @@ sub _flagtypes_nonchanged { all_values => $value, quoted => $dbh->quote($value), joins => [], + bugs_table => "bugs_$chart_id", }; $self->_do_operator_function($subselect_args); my $subselect_term = $subselect_args->{term}; @@ -2661,7 +2954,7 @@ sub _flagtypes_nonchanged { # don't call build_subselect as this must run as a true sub-select $args->{term} = "EXISTS ( SELECT 1 - FROM bugs bugs_$chart_id + FROM $bugs_table bugs_$chart_id LEFT JOIN attachments AS attachments_$chart_id ON bugs_$chart_id.bug_id = attachments_$chart_id.bug_id LEFT JOIN flags AS flags_$chart_id @@ -2670,7 +2963,7 @@ sub _flagtypes_nonchanged { OR flags_$chart_id.attach_id IS NULL) LEFT JOIN flagtypes AS flagtypes_$chart_id ON flags_$chart_id.type_id = flagtypes_$chart_id.id - WHERE bugs_$chart_id.bug_id = bugs.bug_id + WHERE bugs_$chart_id.bug_id = $bugs_table.bug_id AND $subselect_term )"; } @@ -2747,12 +3040,126 @@ sub _multiselect_table { sub _multiselect_term { my ($self, $args, $not) = @_; + my ($operator) = $args->{operator}; + # 'empty' operators require special handling + return $self->_multiselect_isempty($args, $not) + if $operator =~ /^is(not)?empty$/; my $table = $self->_multiselect_table($args); $self->_do_operator_function($args); my $term = $args->{term}; $term .= $args->{_extra_where} || ''; my $select = $args->{_select_field} || 'bug_id'; - return build_subselect("bugs.bug_id", $select, $table, $term, $not); + return build_subselect("$args->{bugs_table}.bug_id", $select, $table, $term, $not); +} + +# We can't use the normal operator_functions to build isempty queries which +# join to different tables. +sub _multiselect_isempty { + my ($self, $args, $not) = @_; + my ($field, $operator, $joins, $chart_id) = @$args{qw(field operator joins chart_id)}; + my $dbh = Bugzilla->dbh; + $operator = $self->_reverseoperator($operator) if $not; + $not = $operator eq 'isnotempty' ? 'NOT' : ''; + + if ($field eq 'keywords') { + push @$joins, { + table => 'keywords', + as => "keywords_$chart_id", + from => 'bug_id', + to => 'bug_id', + }; + return "keywords_$chart_id.bug_id IS $not NULL"; + } + elsif ($field eq 'bug_group') { + push @$joins, { + table => 'bug_group_map', + as => "bug_group_map_$chart_id", + from => 'bug_id', + to => 'bug_id', + }; + return "bug_group_map_$chart_id.bug_id IS $not NULL"; + } + elsif ($field eq 'flagtypes.name') { + push @$joins, { + table => 'flags', + as => "flags_$chart_id", + from => 'bug_id', + to => 'bug_id', + }; + return "flags_$chart_id.bug_id IS $not NULL"; + } + elsif ($field eq 'blocked' or $field eq 'dependson') { + my $to = $field eq 'blocked' ? 'dependson' : 'blocked'; + push @$joins, { + table => 'dependencies', + as => "dependencies_$chart_id", + from => 'bug_id', + to => $to, + }; + return "dependencies_$chart_id.$to IS $not NULL"; + } + elsif ($field eq 'longdesc') { + my @extra = ( "longdescs_$chart_id.type != " . CMT_HAS_DUPE ); + push @extra, "longdescs_$chart_id.isprivate = 0" + unless $self->_user->is_insider; + push @$joins, { + table => 'longdescs', + as => "longdescs_$chart_id", + from => 'bug_id', + to => 'bug_id', + extra => \@extra, + }; + return $not + ? "longdescs_$chart_id.thetext != ''" + : "longdescs_$chart_id.thetext = ''"; + } + elsif ($field eq 'longdescs.isprivate') { + ThrowUserError('search_field_operator_invalid', { field => $field, + operator => $operator }); + } + elsif ($field =~ /^attachments\.(.+)/) { + my $sub_field = $1; + if ($sub_field eq 'description' || $sub_field eq 'filename' || $sub_field eq 'mimetype') { + # can't be null/empty + return $not ? '1=1' : '1=2'; + } else { + # all other fields which get here are boolean + ThrowUserError('search_field_operator_invalid', { field => $field, + operator => $operator }); + } + } + elsif ($field eq 'attach_data.thedata') { + push @$joins, { + table => 'attachments', + as => "attachments_$chart_id", + from => 'bug_id', + to => 'bug_id', + extra => [ $self->_user->is_insider ? '' : "attachments_$chart_id.isprivate = 0" ], + }; + push @$joins, { + table => 'attach_data', + as => "attach_data_$chart_id", + from => "attachments_$chart_id.attach_id", + to => 'id', + }; + return "attach_data_$chart_id.thedata IS $not NULL"; + } + elsif ($field eq 'tag') { + push @$joins, { + table => 'bug_tag', + as => "bug_tag_$chart_id", + from => 'bug_id', + to => 'bug_id', + }; + push @$joins, { + table => 'tag', + as => "tag_$chart_id", + from => "bug_tag_$chart_id.tag_id", + to => 'id', + extra => [ "tag_$chart_id.user_id = " . ($self->_sharer_id || $self->_user->id) ], + }; + return "tag_$chart_id.id IS $not NULL"; + } } ############################### @@ -2829,14 +3236,14 @@ sub _anywordsubstr { my ($self, $args) = @_; my @terms = $self->_substring_terms($args); - $args->{term} = join("\n\tOR ", @terms); + $args->{term} = @terms ? '(' . join("\n\tOR ", @terms) . ')' : ''; } sub _allwordssubstr { my ($self, $args) = @_; my @terms = $self->_substring_terms($args); - $args->{term} = join("\n\tAND ", @terms); + $args->{term} = @terms ? '(' . join("\n\tAND ", @terms) . ')' : ''; } sub _nowordssubstr { @@ -2848,19 +3255,19 @@ sub _nowordssubstr { sub _anywords { my ($self, $args) = @_; - + my @terms = $self->_word_terms($args); # Because _word_terms uses AND, we need to parenthesize its terms # if there are more than one. @terms = map("($_)", @terms) if scalar(@terms) > 1; - $args->{term} = join("\n\tOR ", @terms); + $args->{term} = @terms ? '(' . join("\n\tOR ", @terms) . ')' : ''; } sub _allwords { my ($self, $args) = @_; - + my @terms = $self->_word_terms($args); - $args->{term} = join("\n\tAND ", @terms); + $args->{term} = @terms ? '(' . join("\n\tAND ", @terms) . ')' : ''; } sub _nowords { @@ -2971,6 +3378,27 @@ sub _changed_security_check { } } +sub _isempty { + my ($self, $args) = @_; + my $full_field = $args->{full_field}; + $args->{term} = "$full_field IS NULL OR $full_field = " . $self->_empty_value($args->{field}); +} + +sub _isnotempty { + my ($self, $args) = @_; + my $full_field = $args->{full_field}; + $args->{term} = "$full_field IS NOT NULL AND $full_field != " . $self->_empty_value($args->{field}); +} + +sub _empty_value { + my ($self, $field) = @_; + my $field_obj = $self->_chart_fields->{$field}; + return "0" if $field_obj->type == FIELD_TYPE_BUG_ID; + return Bugzilla->dbh->quote(EMPTY_DATETIME) if $field_obj->type == FIELD_TYPE_DATETIME; + return Bugzilla->dbh->quote(EMPTY_DATE) if $field_obj->type == FIELD_TYPE_DATE; + return "''"; +} + ###################### # Public Subroutines # ###################### @@ -2979,7 +3407,8 @@ sub _changed_security_check { sub IsValidQueryType { my ($queryType) = @_; - if (grep { $_ eq $queryType } qw(specific advanced)) { + # BMO: Added google and instant + if (grep { $_ eq $queryType } qw(specific advanced google instant)) { return 1; } return 0; @@ -2995,8 +3424,8 @@ sub split_order_term { # Used to translate old SQL fragments from buglist.cgi's "order" argument # into our modern field IDs. -sub translate_old_column { - my ($column) = @_; +sub _translate_old_column { + my ($self, $column) = @_; # All old SQL fragments have a period in them somewhere. return $column if $column !~ /\./; @@ -3010,12 +3439,124 @@ sub translate_old_column { # If it doesn't match the regexps above, check to see if the old # SQL fragment matches the SQL of an existing column - foreach my $key (%{ COLUMNS() }) { - next unless exists COLUMNS->{$key}->{name}; - return $key if COLUMNS->{$key}->{name} eq $column; + foreach my $key (%{ $self->COLUMNS }) { + next unless exists $self->COLUMNS->{$key}->{name}; + return $key if $self->COLUMNS->{$key}->{name} eq $column; } return $column; } +# BMO - make product aliases lowercase +foreach my $name (keys %{ PRODUCT_ALIASES() }) { + PRODUCT_ALIASES->{lc($name)} = PRODUCT_ALIASES->{$name}; + delete PRODUCT_ALIASES->{$name}; +} + 1; + +__END__ + +=head1 NAME + +Bugzilla::Search - Provides methods to run queries against bugs. + +=head1 SYNOPSIS + + use Bugzilla::Search; + + my $search = new Bugzilla::Search({'fields' => \@fields, + 'params' => \%search_criteria, + 'sharer' => $sharer_id, + 'user' => $user_obj, + 'allow_unlimited' => 1}); + + my $data = $search->data; + my ($data, $extra_data) = $search->data; + +=head1 DESCRIPTION + +Search.pm represents a search object. It's the single way to collect +data about bugs in a secure way. The list of bugs matching criteria +defined by the caller are filtered based on the user privileges. + +=head1 METHODS + +=head2 new + +=over + +=item B<Description> + +Create a Bugzilla::Search object. + +=item B<Params> + +=over + +=item C<fields> + +An arrayref representing the bug attributes for which data is desired. +Legal attributes are listed in the fielddefs DB table. At least one field +must be defined, typically the 'bug_id' field. + +=item C<params> + +A hashref representing search criteria. Each key => value pair represents +a search criteria, where the key is the search field and the value is the +value for this field. At least one search criteria must be defined if the +'search_allow_no_criteria' parameter is turned off, else an error is thrown. + +=item C<sharer> + +When a saved search is shared by a user, this is his user ID. + +=item C<user> + +A L<Bugzilla::User> object representing the user to whom the data is addressed. +All security checks are done based on this user object, so it's not safe +to share results of the query with other users as not all users have the +same privileges or have the same role for all bugs in the list. If this +parameter is not defined, then the currently logged in user is taken into +account. If no user is logged in, then only public bugs will be returned. + +=item C<allow_unlimited> + +If set to a true value, the number of bugs retrieved by the query is not +limited. + +=back + +=item B<Returns> + +A L<Bugzilla::Search> object. + +=back + +=head2 data + +=over + +=item B<Description> + +Returns bugs matching search criteria passed to C<new()>. + +=item B<Params> + +None + +=item B<Returns> + +In scalar context, this method returns a reference to a list of bugs. +Each item of the list represents a bug, which is itself a reference to +a list where each item represents a bug attribute, in the same order as +specified in the C<fields> parameter of C<new()>. + +In list context, this methods also returns a reference to a list containing +references to hashes. For each hash, two keys are defined: C<sql> contains +the SQL query which has been executed, and C<time> contains the time spent +to execute the SQL query, in seconds. There can be either a single hash, or +two hashes if two SQL queries have been executed sequentially to get all the +required data. + +=back diff --git a/Bugzilla/Search/Clause.pm b/Bugzilla/Search/Clause.pm index 5f5ea5b50..89210babb 100644 --- a/Bugzilla/Search/Clause.pm +++ b/Bugzilla/Search/Clause.pm @@ -42,6 +42,11 @@ sub children { return $self->{children}; } +sub update_search_args { + my ($self, $search_args) = @_; + # abstract +} + sub joiner { return $_[0]->{joiner} } sub has_translated_conditions { @@ -83,7 +88,7 @@ sub walk_conditions { my ($self, $callback) = @_; foreach my $child (@{ $self->children }) { if ($child->isa('Bugzilla::Search::Condition')) { - $callback->($child); + $callback->($self, $child); } else { $child->walk_conditions($callback); diff --git a/Bugzilla/Search/ClauseGroup.pm b/Bugzilla/Search/ClauseGroup.pm new file mode 100644 index 000000000..5b437afec --- /dev/null +++ b/Bugzilla/Search/ClauseGroup.pm @@ -0,0 +1,96 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Search::ClauseGroup; + +use strict; + +use base qw(Bugzilla::Search::Clause); + +use Bugzilla::Error; +use Bugzilla::Search::Condition qw(condition); +use Bugzilla::Util qw(trick_taint); +use List::MoreUtils qw(uniq); + +use constant UNSUPPORTED_FIELDS => qw( + attach_data.thedata + classification + commenter + component + longdescs.count + product + owner_idle_time +); + +sub new { + my ($class) = @_; + my $self = bless({ joiner => 'AND' }, $class); + # Add a join back to the bugs table which will be used to group conditions + # for this clause + my $condition = Bugzilla::Search::Condition->new({}); + $condition->translated({ + joins => [{ + table => 'bugs', + as => 'bugs_g0', + from => 'bug_id', + to => 'bug_id', + extra => [], + }], + term => '1 = 1', + }); + $self->SUPER::add($condition); + $self->{group_condition} = $condition; + return $self; +} + +sub add { + my ($self, @args) = @_; + my $field = scalar(@args) == 3 ? $args[0] : $args[0]->{field}; + + # We don't support nesting of conditions under this clause + if (scalar(@args) == 1 && !$args[0]->isa('Bugzilla::Search::Condition')) { + ThrowUserError('search_grouped_invalid_nesting'); + } + + # Ensure all conditions use the same field + if (!$self->{_field}) { + $self->{_field} = $field; + } elsif ($field ne $self->{_field}) { + ThrowUserError('search_grouped_field_mismatch'); + } + + # Unsupported fields + if (grep { $_ eq $field } UNSUPPORTED_FIELDS ) { + ThrowUserError('search_grouped_field_invalid', { field => $field }); + } + + $self->SUPER::add(@args); +} + +sub update_search_args { + my ($self, $search_args) = @_; + + # No need to change things if there's only one child condition + return unless scalar(@{ $self->children }) > 1; + + # we want all the terms to use the same join table + if (!exists $self->{_first_chart_id}) { + $self->{_first_chart_id} = $search_args->{chart_id}; + } else { + $search_args->{chart_id} = $self->{_first_chart_id}; + } + + my $suffix = '_g' . $self->{_first_chart_id}; + $self->{group_condition}->{translated}->{joins}->[0]->{as} = "bugs$suffix"; + + $search_args->{full_field} =~ s/^bugs\./bugs$suffix\./; + + $search_args->{table_suffix} = $suffix; + $search_args->{bugs_table} = "bugs$suffix"; +} + +1; diff --git a/Bugzilla/Search/Quicksearch.pm b/Bugzilla/Search/Quicksearch.pm index fd9d796d1..9c252d04f 100644 --- a/Bugzilla/Search/Quicksearch.pm +++ b/Bugzilla/Search/Quicksearch.pm @@ -151,7 +151,7 @@ sub quicksearch { # Retain backslashes and quotes, to know which strings are quoted, # and which ones are not. - my @words = parse_line('\s+', 1, $searchstring); + my @words = _parse_line('\s+', 1, $searchstring); # If parse_line() returns no data, this means strings are badly quoted. # Rather than trying to guess what the user wanted to do, we throw an error. scalar(@words) @@ -161,6 +161,8 @@ sub quicksearch { ThrowUserError('quicksearch_invalid_query') if ($words[0] =~ /^(?:AND|OR)$/ || $words[$#words] =~ /^(?:AND|OR|NOT)$/); + $fulltext = Bugzilla->user->setting('quicksearch_fulltext') eq 'on' ? 1 : 0; + my (@qswords, @or_group); while (scalar @words) { my $word = shift @words; @@ -187,6 +189,10 @@ sub quicksearch { } unshift(@words, "-$word"); } + # --comment and ++comment disable or enable fulltext searching + elsif ($word =~ /^(--|\+\+)comments?$/i) { + $fulltext = $1 eq '--' ? 0 : 1; + } else { # OR groups words together, as OR has higher precedence than AND. push(@or_group, $word); @@ -203,12 +209,12 @@ sub quicksearch { shift(@qswords) if $bug_status_set; my (@unknownFields, %ambiguous_fields); - $fulltext = Bugzilla->user->setting('quicksearch_fulltext') eq 'on' ? 1 : 0; # Loop over all main-level QuickSearch words. foreach my $qsword (@qswords) { - my @or_operand = parse_line('\|', 1, $qsword); + my @or_operand = _parse_line('\|', 1, $qsword); foreach my $term (@or_operand) { + next unless defined $term; my $negate = substr($term, 0, 1) eq '-'; if ($negate) { $term = substr($term, 1); @@ -221,7 +227,7 @@ sub quicksearch { # Having ruled out the special cases, we may now split # by comma, which is another legal boolean OR indicator. # Remove quotes from quoted words, if any. - @words = parse_line(',', 0, $term); + @words = _parse_line(',', 0, $term); foreach my $word (@words) { if (!_special_field_syntax($word, $negate)) { _default_quicksearch_word($word, $negate); @@ -273,13 +279,36 @@ sub quicksearch { # Parts of quicksearch() # ########################## +sub _parse_line { + my ($delim, $keep, $line) = @_; + return () unless defined $line; + + # parse_line always treats ' as a quote character, making it impossible + # to sanely search for contractions. As this behavour isn't + # configurable, we replace ' with a placeholder to hide it from the + # parser. + + # only treat ' at the start or end of words as quotes + # it's easier to do this in reverse with regexes + $line =~ s/(^|\s|:)'/$1\001/g; + $line =~ s/'($|\s)/\001$1/g; + $line =~ s/\\?'/\000/g; + $line =~ tr/\001/'/; + + my @words = parse_line($delim, $keep, $line); + foreach my $word (@words) { + $word =~ tr/\000/'/ if defined $word; + } + return @words; +} + sub _bug_numbers_only { my $searchstring = shift; my $cgi = Bugzilla->cgi; # Allow separation by comma or whitespace. $searchstring =~ s/[,\s]+/,/g; - if ($searchstring !~ /,/) { + if ($searchstring !~ /,/ && !i_am_webservice()) { # Single bug number; shortcut to show_bug.cgi. print $cgi->redirect( -uri => correct_urlbase() . "show_bug.cgi?id=$searchstring"); @@ -298,9 +327,11 @@ sub _handle_alias { if ($searchstring =~ /^([^,\s]+)$/) { my $alias = $1; # We use this direct SQL because we want quicksearch to be VERY fast. - my $is_alias = Bugzilla->dbh->selectrow_array( - q{SELECT 1 FROM bugs WHERE alias = ?}, undef, $alias); - if ($is_alias) { + my $bug_id = Bugzilla->dbh->selectrow_array( + q{SELECT bug_id FROM bugs WHERE alias = ?}, undef, $alias); + # If the user cannot see the bug or if we are using a webservice, + # do not resolve its alias. + if ($bug_id && Bugzilla->user->can_see_bug($bug_id) && !i_am_webservice()) { $alias = url_quote($alias); print Bugzilla->cgi->redirect( -uri => correct_urlbase() . "show_bug.cgi?id=$alias"); @@ -339,6 +370,7 @@ sub _handle_status_and_resolution { sub _handle_special_first_chars { my ($qsword, $negate) = @_; + return 0 if !defined $qsword || length($qsword) <= 1; my $firstChar = substr($qsword, 0, 1); my $baseWord = substr($qsword, 1); @@ -377,23 +409,26 @@ sub _handle_field_names { # Flag and requestee shortcut if ($or_operand =~ /^(?:flag:)?([^\?]+\?)([^\?]*)$/) { - my ($flagtype, $requestee) = ($1, $2); - addChart('flagtypes.name', 'substring', $flagtype, $negate); - if ($requestee) { - # AND - $chart++; - $and = $or = 0; - addChart('requestees.login_name', 'substring', $requestee, $negate); + # BMO: Do not treat custom fields as flags if value is ? + if ($1 !~ /^cf_/) { + my ($flagtype, $requestee) = ($1, $2); + addChart('flagtypes.name', 'substring', $flagtype, $negate); + if ($requestee) { + # AND + $chart++; + $and = $or = 0; + addChart('requestees.login_name', 'substring', $requestee, $negate); + } + return 1; } - return 1; } # Generic field1,field2,field3:value1,value2 notation. # We have to correctly ignore commas and colons in quotes. - my @field_values = parse_line(':', 1, $or_operand); + my @field_values = _parse_line(':', 1, $or_operand); if (scalar @field_values == 2) { - my @fields = parse_line(',', 1, $field_values[0]); - my @values = parse_line(',', 1, $field_values[1]); + my @fields = _parse_line(',', 1, $field_values[0]); + my @values = _parse_line(',', 1, $field_values[1]); foreach my $field (@fields) { my $translated = _translate_field_name($field); # Skip and record any unknown fields @@ -410,6 +445,7 @@ sub _handle_field_names { $bug_status_set = 1; } foreach my $value (@values) { + next unless defined $value; my $operator = FIELD_OPERATOR->{$translated} || 'substring'; # If the string was quoted to protect some special # characters such as commas and colons, we need @@ -482,6 +518,7 @@ sub _translate_field_name { sub _special_field_syntax { my ($word, $negate) = @_; + return unless defined($word); # P1-5 Syntax if ($word =~ m/^P(\d+)(?:-(\d+))?$/i) { @@ -517,6 +554,7 @@ sub _special_field_syntax { sub _default_quicksearch_word { my ($word, $negate) = @_; + return unless defined($word); if (!grep { lc($word) eq $_ } PRODUCT_EXCEPTIONS and length($word) > 2) { addChart('product', 'substring', $word, $negate); @@ -535,10 +573,15 @@ sub _default_quicksearch_word { addChart('short_desc', 'substring', $word, $negate); addChart('status_whiteboard', 'substring', $word, $negate); addChart('content', 'matches', _matches_phrase($word), $negate) if $fulltext; + + # BMO Bug 664124 - Include the crash signature (sig:) field in default quicksearches + addChart('cf_crash_signature', 'substring', $word, $negate); } sub _handle_urls { my ($word, $negate) = @_; + return unless defined($word); + # URL field (for IP addrs, host.names, # scheme://urls) if ($word =~ m/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ diff --git a/Bugzilla/Search/Recent.pm b/Bugzilla/Search/Recent.pm index 5f04b180b..125850e85 100644 --- a/Bugzilla/Search/Recent.pm +++ b/Bugzilla/Search/Recent.pm @@ -65,12 +65,13 @@ sub create { my $user_id = $search->user_id; # Enforce there only being SAVE_NUM_SEARCHES per user. - my $min_id = $dbh->selectrow_array( - 'SELECT id FROM profile_search WHERE user_id = ? ORDER BY id DESC ' - . $dbh->sql_limit(1, SAVE_NUM_SEARCHES), undef, $user_id); - if ($min_id) { - $dbh->do('DELETE FROM profile_search WHERE user_id = ? AND id <= ?', - undef, ($user_id, $min_id)); + my @ids = @{ $dbh->selectcol_arrayref( + "SELECT id FROM profile_search WHERE user_id = ? ORDER BY id", + undef, $user_id) }; + if (scalar(@ids) > SAVE_NUM_SEARCHES) { + splice(@ids, - SAVE_NUM_SEARCHES); + $dbh->do( + "DELETE FROM profile_search WHERE id IN (" . join(',', @ids) . ")"); } $dbh->bz_commit_transaction(); return $search; diff --git a/Bugzilla/Send/Sendmail.pm b/Bugzilla/Send/Sendmail.pm new file mode 100644 index 000000000..9513134f4 --- /dev/null +++ b/Bugzilla/Send/Sendmail.pm @@ -0,0 +1,95 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Send::Sendmail; + +use strict; + +use base qw(Email::Send::Sendmail); + +use Return::Value; +use Symbol qw(gensym); + +sub send { + my ($class, $message, @args) = @_; + my $mailer = $class->_find_sendmail; + + return failure "Couldn't find 'sendmail' executable in your PATH" + ." and Email::Send::Sendmail::SENDMAIL is not set" + unless $mailer; + + return failure "Found $mailer but cannot execute it" + unless -x $mailer; + + local $SIG{'CHLD'} = 'DEFAULT'; + + my $pipe = gensym; + + open($pipe, "| $mailer -t -oi @args") + || return failure "Error executing $mailer: $!"; + print($pipe $message->as_string) + || return failure "Error printing via pipe to $mailer: $!"; + unless (close $pipe) { + return failure "error when closing pipe to $mailer: $!" if $!; + my ($error_message, $is_transient) = _map_exitcode($? >> 8); + if (Bugzilla->params->{'use_mailer_queue'}) { + # Return success for errors which are fatal so Bugzilla knows to + # remove them from the queue + if ($is_transient) { + return failure "error when closing pipe to $mailer: $error_message"; + } else { + warn "error when closing pipe to $mailer: $error_message\n"; + return success; + } + } else { + return failure "error when closing pipe to $mailer: $error_message"; + } + } + return success; +} + +sub _map_exitcode { + # Returns (error message, is_transient) + # from the sendmail source (sendmail/sysexit.h) + my $code = shift; + if ($code == 64) { + return ("Command line usage error (EX_USAGE)", 1); + } elsif ($code == 65) { + return ("Data format error (EX_DATAERR)", 1); + } elsif ($code == 66) { + return ("Cannot open input (EX_NOINPUT)", 1); + } elsif ($code == 67) { + return ("Addressee unknown (EX_NOUSER)", 0); + } elsif ($code == 68) { + return ("Host name unknown (EX_NOHOST)", 0); + } elsif ($code == 69) { + return ("Service unavailable (EX_UNAVAILABLE)", 1); + } elsif ($code == 70) { + return ("Internal software error (EX_SOFTWARE)", 1); + } elsif ($code == 71) { + return ("System error (EX_OSERR)", 1); + } elsif ($code == 72) { + return ("Critical OS file missing (EX_OSFILE)", 1); + } elsif ($code == 73) { + return ("Can't create output file (EX_CANTCREAT)", 1); + } elsif ($code == 74) { + return ("Input/output error (EX_IOERR)", 1); + } elsif ($code == 75) { + return ("Temp failure (EX_TEMPFAIL)", 1); + } elsif ($code == 76) { + return ("Remote error in protocol (EX_PROTOCOL)", 1); + } elsif ($code == 77) { + return ("Permission denied (EX_NOPERM)", 1); + } elsif ($code == 78) { + return ("Configuration error (EX_CONFIG)", 1); + } else { + return ("Unknown Error ($code)", 1); + } +} + +1; + diff --git a/Bugzilla/Sentry.pm b/Bugzilla/Sentry.pm new file mode 100644 index 000000000..d2994e78b --- /dev/null +++ b/Bugzilla/Sentry.pm @@ -0,0 +1,318 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Sentry; + +use strict; +use warnings; + +use base qw(Exporter); +our @EXPORT = qw( + sentry_handle_error + sentry_should_notify +); + +use Apache2::Log; +use Apache2::SubProcess; +use Carp; +use Data::Dumper; +use DateTime; +use File::Temp; +use LWP::UserAgent; +use Sys::Hostname; +use URI; + +use Bugzilla::Constants; +use Bugzilla::RNG qw(irand); +use Bugzilla::Util; +use Bugzilla::WebService::Constants; + +use constant CONFIG => { + # 'codes' lists the code-errors which are sent to sentry + codes => [qw( + bug_error + chart_datafile_corrupt + chart_dir_nonexistent + chart_file_open_fail + illegal_content_type_method + jobqueue_insert_failed + ldap_bind_failed + mail_send_error + template_error + token_generation_error + )], + + # any error/warning messages matching these regex's will not be logged or + # sent to sentry + ignore => [ + qr/^compiled template :\s*$/, + qr/^Use of uninitialized value \$compiled in concatenation \(\.\) or string/, + ], + + # any error/warning messages matching these regex's will be logged but not + # sent to sentry + sentry_ignore => [ + qr/Software caused connection abort/, + qr/Could not check out .*\/cvsroot/, + qr/Unicode character \S+ is illegal/, + qr/Lost connection to MySQL server during query/, + qr/Call me again when you have some data to chart/, + qr/relative paths are not allowed/, + ], + + # (ab)use the logger to classify error/warning types + logger => [ + { + match => [ + qr/DBD::mysql/, + qr/Can't connect to the database/, + ], + logger => 'database_error', + }, + { + match => [ qr/PatchReader/ ], + logger => 'patchreader', + }, + { + match => [ qr/Use of uninitialized value/ ], + logger => 'uninitialized_warning', + }, + ], +}; + +sub sentry_generate_id { + return sprintf('%04x%04x%04x%04x%04x%04x%04x%04x', + irand(0xffff), irand(0xffff), + irand(0xffff), + irand(0x0fff) | 0x4000, + irand(0x3fff) | 0x8000, + irand(0xffff), irand(0xffff), irand(0xffff) + ); +} + +sub sentry_should_notify { + my $code_error = shift; + return grep { $_ eq $code_error } @{ CONFIG->{codes} }; +} + +sub sentry_handle_error { + my $level = shift; + my @message = split(/\n/, shift); + my $id = sentry_generate_id(); + + my $is_error = $level eq 'error'; + if ($level ne 'error' && $level ne 'warning') { + # it's a code-error + return 0 unless sentry_should_notify($level); + $is_error = 1; + $level = 'error'; + } + + # build traceback + my $traceback; + { + # for now don't show function arguments, in case they contain + # confidential data. waiting on bug 700683 + #local $Carp::MaxArgLen = 256; + #local $Carp::MaxArgNums = 0; + local $Carp::MaxArgNums = -1; + local $Carp::CarpInternal{'CGI::Carp'} = 1; + local $Carp::CarpInternal{'Bugzilla::Error'} = 1; + local $Carp::CarpInternal{'Bugzilla::Sentry'} = 1; + $traceback = trim(Carp::longmess()); + } + + # strip timestamp + foreach my $line (@message) { + $line =~ s/^\[[^\]]+\] //; + } + my $message = join(" ", map { trim($_) } grep { $_ ne '' } @message); + + # message content filtering + foreach my $re (@{ CONFIG->{ignore} }) { + return 0 if $message =~ $re; + } + + # determine logger + my $logger; + foreach my $config (@{ CONFIG->{logger} }) { + foreach my $re (@{ $config->{match} }) { + if ($message =~ $re) { + $logger = $config->{logger}; + last; + } + } + last if $logger; + } + $logger ||= $level; + + # don't send to sentry unless configured + my $send_to_sentry = Bugzilla->params->{sentry_uri} ? 1 : 0; + + # web service filtering + if ($send_to_sentry + && (Bugzilla->error_mode == ERROR_MODE_DIE_SOAP_FAULT || Bugzilla->error_mode == ERROR_MODE_JSON_RPC)) + { + my ($code) = $message =~ /^(-?\d+): /; + if ($code + && !($code == ERROR_UNKNOWN_FATAL || $code == ERROR_UNKNOWN_TRANSIENT)) + { + $send_to_sentry = 0; + } + } + + # message content filtering + if ($send_to_sentry) { + foreach my $re (@{ CONFIG->{sentry_ignore} }) { + if ($message =~ $re) { + $send_to_sentry = 0; + last; + } + } + } + + # for now, don't send patchreader errors to sentry + $send_to_sentry = 0 + if $logger eq 'patchreader'; + + # log to apache's error_log + if ($send_to_sentry) { + _write_to_error_log("$message [#$id]", $is_error); + } else { + $traceback =~ s/\n/ /g; + _write_to_error_log("$message $traceback", $is_error); + } + + return 0 unless $send_to_sentry; + + my $user_data = undef; + eval { + my $user = Bugzilla->user; + if ($user->id) { + $user_data = { + id => $user->login, + name => $user->name, + }; + } + }; + + my $uri = URI->new(Bugzilla->cgi->self_url); + $uri->query(undef); + + my $data = { + event_id => $id, + message => $message, + timestamp => DateTime->now->iso8601(), + level => $level, + platform => 'Other', + logger => $logger, + server_name => hostname(), + 'sentry.interfaces.User' => $user_data, + 'sentry.interfaces.Http' => { + url => $uri->as_string, + method => $ENV{REQUEST_METHOD}, + query_string => $ENV{QUERY_STRING}, + env => \%ENV, + }, + extra => { + stacktrace => $traceback, + }, + }; + + my $fh = File::Temp->new( UNLINK => 0 ); + if (!$fh) { + warn "Failed to create temp file: $!\n"; + return; + } + print $fh Dumper($data); + close($fh) or die $!; + my $filename = $fh->filename; + + my $command = bz_locations()->{'cgi_path'} . "/sentry.pl '$filename' &"; + system($command); + return 1; +} + +sub _write_to_error_log { + my ($message, $is_error) = @_; + if ($ENV{MOD_PERL}) { + if ($is_error) { + Apache2::ServerRec::log_error($message); + } else { + Apache2::ServerRec::warn($message); + } + } else { + print STDERR "$message\n"; + } +} + +# lifted from Bugzilla::Error +sub _in_eval { + my $in_eval = 0; + for (my $stack = 1; my $sub = (caller($stack))[3]; $stack++) { + last if $sub =~ /^ModPerl/; + last if $sub =~ /^Bugzilla::Template/; + $in_eval = 1 if $sub =~ /^\(eval\)/; + } + return $in_eval; +} + +sub _sentry_die_handler { + my $message = shift; + $message =~ s/^undef error - //; + + # avoid recursion, and check for CGI::Carp::die failures + my $in_cgi_carp_die = 0; + for (my $stack = 1; my $sub = (caller($stack))[3]; $stack++) { + return if $sub =~ /:_sentry_die_handler$/; + $in_cgi_carp_die = 1 if $sub =~ /CGI::Carp::die$/; + } + + return if _in_eval(); + + # mod_perl overrides exit to call die with this string + exit if $message =~ /\bModPerl::Util::exit\b/; + + my $nested_error = ''; + my $is_compilation_failure = $message =~ /\bcompilation (aborted|failed)\b/i; + + # if we are called via CGI::Carp::die chances are something is seriously + # wrong, so skip trying to use ThrowTemplateError + if (!$in_cgi_carp_die && !$is_compilation_failure) { + eval { Bugzilla::Error::ThrowTemplateError($message) }; + $nested_error = $@ if $@; + } + + if ($is_compilation_failure || + $in_cgi_carp_die || + ($nested_error && $nested_error !~ /\bModPerl::Util::exit\b/) + ) { + sentry_handle_error('error', $message); + + # and call the normal error management + # (ISE for web pages, error response for web services, etc) + CORE::die($message); + } + exit; +} + +sub install_sentry_handler { + require CGI::Carp; + CGI::Carp::set_die_handler(\&_sentry_die_handler); + $main::SIG{__WARN__} = sub { + return if _in_eval(); + sentry_handle_error('warning', shift); + }; +} + +BEGIN { + if ($ENV{SCRIPT_NAME} || $ENV{MOD_PERL}) { + install_sentry_handler(); + } +} + +1; diff --git a/Bugzilla/Template.pm b/Bugzilla/Template.pm index cd7507963..81202965c 100644 --- a/Bugzilla/Template.pm +++ b/Bugzilla/Template.pm @@ -236,7 +236,8 @@ sub quoteUrls { ~<a href=\"mailto:$2\">$1$2</a>~igx; # attachment links - $text =~ s~\b(attachment\s*\#?\s*(\d+)(?:\s+\[details\])?) + # BMO: Bug 652332 dkl@mozilla.com 2011-07-20 + $text =~ s~\b(attachment\s*\#?\s*(\d+)(?:\s+\[diff\])?(?:\s+\[details\])?) ~($things[$count++] = get_attachment_link($2, $1, $user)) && ("\0\0" . ($count-1) . "\0\0") ~egmxi; @@ -281,7 +282,7 @@ sub get_attachment_link { my $dbh = Bugzilla->dbh; $user ||= Bugzilla->user; - my $attachment = new Bugzilla::Attachment($attachid); + my $attachment = new Bugzilla::Attachment({ id => $attachid, cache => 1 }); if ($attachment) { my $title = ""; @@ -298,19 +299,21 @@ sub get_attachment_link { $title = html_quote(clean_text($title)); $link_text =~ s/ \[details\]$//; + $link_text =~ s/ \[diff\]$//; my $linkval = "attachment.cgi?id=$attachid"; - # If the attachment is a patch, try to link to the diff rather - # than the text, by default. + # If the attachment is a patch and patch_viewer feature is + # enabled, add link to the diff. my $patchlink = ""; if ($attachment->ispatch and Bugzilla->feature('patch_viewer')) { - $patchlink = '&action=diff'; + $patchlink = qq| <a href="${linkval}&action=diff" title="$title">[diff]</a>|; } # Whitespace matters here because these links are in <pre> tags. return qq|<span class="$className">| - . qq|<a href="${linkval}${patchlink}" name="attach_${attachid}" title="$title">$link_text</a>| + . qq|<a href="${linkval}" name="attach_${attachid}" title="$title">$link_text</a>| . qq| <a href="${linkval}&action=edit" title="$title">[details]</a>| + . qq|${patchlink}| . qq|</span>|; } else { @@ -331,8 +334,8 @@ sub get_bug_link { $options->{user} ||= Bugzilla->user; my $dbh = Bugzilla->dbh; - if (defined $bug) { - $bug = blessed($bug) ? $bug : new Bugzilla::Bug($bug); + if (defined $bug && $bug ne '') { + $bug = blessed($bug) ? $bug : new Bugzilla::Bug({ id => $bug, cache => 1 }); return $link_text if $bug->{error}; } @@ -399,13 +402,10 @@ sub mtime_filter { # # 1. YUI CSS # 2. Standard Bugzilla stylesheet set (persistent) -# 3. Standard Bugzilla stylesheet set (selectable) -# 4. All third-party "skin" stylesheet sets (selectable) -# 5. Page-specific styles -# 6. Custom Bugzilla stylesheet set (persistent) -# -# "Selectable" skin file sets may be either preferred or alternate. -# Exactly one is preferred, determined by the "skin" user preference. +# 3. Third-party "skin" stylesheet set, per user prefs (persistent) +# 4. Page-specific styles +# 5. Custom Bugzilla stylesheet set (persistent) + sub css_files { my ($style_urls, $yui, $yui_css) = @_; @@ -422,18 +422,10 @@ sub css_files { my @css_sets = map { _css_link_set($_) } @requested_css; - my %by_type = (standard => [], alternate => {}, skin => [], custom => []); + my %by_type = (standard => [], skin => [], custom => []); foreach my $set (@css_sets) { foreach my $key (keys %$set) { - if ($key eq 'alternate') { - foreach my $alternate_skin (keys %{ $set->{alternate} }) { - my $files = $by_type{alternate}->{$alternate_skin} ||= []; - push(@$files, $set->{alternate}->{$alternate_skin}); - } - } - else { - push(@{ $by_type{$key} }, $set->{$key}); - } + push(@{ $by_type{$key} }, $set->{$key}); } } @@ -450,27 +442,15 @@ sub _css_link_set { if ($file_name !~ m{(^|/)skins/standard/}) { return \%set; } - - my $skin_user_prefs = Bugzilla->user->settings->{skin}; + + my $skin = Bugzilla->user->settings->{skin}->{value}; my $cgi_path = bz_locations()->{'cgi_path'}; - # If the DB is not accessible, user settings are not available. - my $all_skins = $skin_user_prefs ? $skin_user_prefs->legal_values : []; - my %skin_urls; - foreach my $option (@$all_skins) { - next if $option eq 'standard'; - my $skin_file_name = $file_name; - $skin_file_name =~ s{(^|/)skins/standard/}{skins/contrib/$option/}; - if (my $mtime = _mtime("$cgi_path/$skin_file_name")) { - $skin_urls{$option} = mtime_filter($skin_file_name, $mtime); - } + my $skin_file_name = $file_name; + $skin_file_name =~ s{(^|/)skins/standard/}{skins/contrib/$skin/}; + if (my $mtime = _mtime("$cgi_path/$skin_file_name")) { + $set{skin} = mtime_filter($skin_file_name, $mtime); } - $set{alternate} = \%skin_urls; - - my $skin = $skin_user_prefs->{'value'}; - if ($skin ne 'standard' and defined $set{alternate}->{$skin}) { - $set{skin} = delete $set{alternate}->{$skin}; - } - + my $custom_file_name = $file_name; $custom_file_name =~ s{(^|/)skins/standard/}{skins/custom/}; if (my $custom_mtime = _mtime("$cgi_path/$custom_file_name")) { @@ -556,10 +536,9 @@ $Template::Stash::SCALAR_OPS->{ 0 } = $Template::Stash::SCALAR_OPS->{ truncate } = sub { my ($string, $length, $ellipsis) = @_; - $ellipsis ||= ""; - return $string if !$length || length($string) <= $length; - + + $ellipsis ||= ''; my $strlen = $length - length($ellipsis); my $newstr = substr($string, 0, $strlen) . $ellipsis; return $newstr; @@ -615,6 +594,10 @@ sub create { COMPILE_DIR => bz_locations()->{'template_cache'}, + # Don't check for a template update until 1 hour has passed since the + # last check. + STAT_TTL => 60 * 60, + # Initialize templates (f.e. by loading plugins like Hook). PRE_PROCESS => ["global/initialize.none.tmpl"], @@ -666,6 +649,18 @@ sub create { $var =~ s/>/\\x3e/g; return $var; }, + + # Sadly, different to the above. See http://www.json.org/ + # for details. + json => sub { + my ($var) = @_; + $var =~ s/([\\\"\/])/\\$1/g; + $var =~ s/\n/\\n/g; + $var =~ s/\r/\\r/g; + $var =~ s/\f/\\f/g; + $var =~ s/\t/\\t/g; + return $var; + }, # Converts data to base64 base64 => sub { @@ -829,9 +824,7 @@ sub create { # (Wrapping the message in the WebService is unnecessary # and causes awkward things like \n's appearing in error # messages in JSON-RPC.) - unless (Bugzilla->usage_mode == USAGE_MODE_JSON - or Bugzilla->usage_mode == USAGE_MODE_XMLRPC) - { + unless (i_am_webservice()) { $var = wrap_comment($var, 72); } $var =~ s/\ / /g; @@ -881,14 +874,9 @@ sub create { # Currently logged in user, if any # If an sudo session is in progress, this is the user we're faking 'user' => sub { return Bugzilla->user; }, - + # Currenly active language - # XXX Eventually this should probably be replaced with something - # like Bugzilla->language. - 'current_language' => sub { - my ($language) = include_languages(); - return $language; - }, + 'current_language' => sub { return Bugzilla->current_language; }, # If an sudo session is in progress, this is the user who # started the session. @@ -899,7 +887,7 @@ sub create { # Allow templates to access docs url with users' preferred language 'docs_urlbase' => sub { - my ($language) = include_languages(); + my $language = Bugzilla->current_language; my $docs_urlbase = Bugzilla->params->{'docs_urlbase'}; $docs_urlbase =~ s/\%lang\%/$language/; return $docs_urlbase; @@ -928,7 +916,15 @@ sub create { Bugzilla->fields({ by_name => 1 }); return $cache->{template_bug_fields}; }, - + + # A general purpose cache to store rendered templates for reuse. + # Make sure to not mix language-specific data. + 'template_cache' => sub { + my $cache = Bugzilla->request_cache->{template_cache} ||= {}; + $cache->{users} ||= {}; + return $cache; + }, + 'css_files' => \&css_files, yui_resolve_deps => \&yui_resolve_deps, @@ -975,6 +971,12 @@ sub create { 'default_authorizer' => sub { return Bugzilla::Auth->new() }, }, }; + # Use a per-process provider to cache compiled templates in memory across + # requests. + my $provider_key = join(':', @{ $config->{INCLUDE_PATH} }); + my $shared_providers = Bugzilla->process_cache->{shared_providers} ||= {}; + $shared_providers->{$provider_key} ||= Template::Provider->new($config); + $config->{LOAD_TEMPLATES} = [ $shared_providers->{$provider_key} ]; local $Template::Config::CONTEXT = 'Bugzilla::Template::Context'; @@ -1056,6 +1058,9 @@ sub precompile_templates { # If anything created a Template object before now, clear it out. delete Bugzilla->request_cache->{template}; + # Clear out the cached Provider object + Bugzilla->process_cache->{shared_providers} = undef; + print install_string('done') . "\n" if $output; } diff --git a/Bugzilla/Template/Context.pm b/Bugzilla/Template/Context.pm index 7923603e5..b3e3446f6 100644 --- a/Bugzilla/Template/Context.pm +++ b/Bugzilla/Template/Context.pm @@ -95,6 +95,14 @@ sub stash { return $stash; } +sub filter { + my ($self, $name, $args) = @_; + # If we pass an alias for the filter name, the filter code is cached + # instead of looking for it at each call. + # If the filter has arguments, then we can't cache it. + $self->SUPER::filter($name, $args, $args ? undef : $name); +} + # We need a DESTROY sub for the same reason that Bugzilla::CGI does. sub DESTROY { my $self = shift; diff --git a/Bugzilla/Token.pm b/Bugzilla/Token.pm index 9c2242f63..24df470ac 100644 --- a/Bugzilla/Token.pm +++ b/Bugzilla/Token.pm @@ -109,6 +109,8 @@ sub IssueEmailChangeToken { $vars->{'newemailaddress'} = $new_email . $email_suffix; $vars->{'expiration_ts'} = ctime($token_ts + MAX_TOKEN_AGE * 86400); $vars->{'token'} = $token; + # For SecureMail extension + $vars->{'to_user'} = $user; $vars->{'emailaddress'} = $old_email . $email_suffix; my $message; diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm index 0bc49d9b1..1c6e68078 100644 --- a/Bugzilla/User.pm +++ b/Bugzilla/User.pm @@ -50,6 +50,7 @@ use Bugzilla::Product; use Bugzilla::Classification; use Bugzilla::Field; use Bugzilla::Group; +use Bugzilla::Hook; use DateTime::TimeZone; use List::Util qw(max); @@ -91,16 +92,21 @@ use constant DB_TABLE => 'profiles'; # that you passed in for "name" to new(). That's because historically # Bugzilla::User used "name" for the realname field. This should be # fixed one day. -use constant DB_COLUMNS => ( - 'profiles.userid', - 'profiles.login_name', - 'profiles.realname', - 'profiles.mybugslink AS showmybugslink', - 'profiles.disabledtext', - 'profiles.disable_mail', - 'profiles.extern_id', - 'profiles.is_enabled', -); +sub DB_COLUMNS { + my $dbh = Bugzilla->dbh; + return ( + 'profiles.userid', + 'profiles.login_name', + 'profiles.realname', + 'profiles.mybugslink AS showmybugslink', + 'profiles.disabledtext', + 'profiles.disable_mail', + 'profiles.extern_id', + 'profiles.is_enabled', + $dbh->sql_date_format('last_seen_date', '%Y-%m-%d') . ' AS last_seen_date', + ), +} + use constant NAME_FIELD => 'login_name'; use constant ID_FIELD => 'userid'; use constant LIST_ORDER => NAME_FIELD; @@ -144,7 +150,7 @@ sub new { my $class = ref($invocant) || $invocant; my ($param) = @_; - my $user = DEFAULT_USER; + my $user = { %{ DEFAULT_USER() } }; bless ($user, $class); return $user unless $param; @@ -162,7 +168,7 @@ sub super_user { my $class = ref($invocant) || $invocant; my ($param) = @_; - my $user = dclone(DEFAULT_USER); + my $user = { %{ DEFAULT_USER() } }; $user->{groups} = [Bugzilla::Group->get_all]; $user->{bless_groups} = [Bugzilla::Group->get_all]; bless $user, $class; @@ -285,6 +291,23 @@ sub set_disabledtext { $_[0]->set('is_enabled', $_[1] ? 0 : 1); } +sub update_last_seen_date { + my $self = shift; + return unless $self->id; + my $dbh = Bugzilla->dbh; + my $date = $dbh->selectrow_array( + 'SELECT ' . $dbh->sql_date_format('NOW()', '%Y-%m-%d')); + + if (!$self->last_seen_date or $date ne $self->last_seen_date) { + $self->{last_seen_date} = $date; + # We don't use the normal update() routine here as we only + # want to update the last_seen_date column, not any other + # pending changes + $dbh->do("UPDATE profiles SET last_seen_date = ? WHERE userid = ?", + undef, $date, $self->id); + } +} + ################################################################################ # Methods ################################################################################ @@ -299,6 +322,7 @@ sub is_enabled { $_[0]->{'is_enabled'} ? 1 : 0; } sub showmybugslink { $_[0]->{showmybugslink}; } sub email_disabled { $_[0]->{disable_mail}; } sub email_enabled { !($_[0]->{disable_mail}); } +sub last_seen_date { $_[0]->{last_seen_date}; } sub cryptpassword { my $self = shift; # We don't store it because we never want it in the object (we @@ -431,6 +455,31 @@ sub tags { return $self->{tags}; } +sub bugs_ignored { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + if (!defined $self->{'bugs_ignored'}) { + $self->{'bugs_ignored'} = $dbh->selectall_arrayref( + 'SELECT bugs.bug_id AS id, + bugs.bug_status AS status, + bugs.short_desc AS summary + FROM bugs + INNER JOIN email_bug_ignore + ON bugs.bug_id = email_bug_ignore.bug_id + WHERE user_id = ?', + { Slice => {} }, $self->id); + # Go ahead and load these into the visible bugs cache + # to speed up can_see_bug checks later + $self->visible_bugs([ map { $_->{'id'} } @{ $self->{'bugs_ignored'} } ]); + } + return $self->{'bugs_ignored'}; +} + +sub is_bug_ignored { + my ($self, $bug_id) = @_; + return (grep {$_->{'id'} == $bug_id} @{$self->bugs_ignored}) ? 1 : 0; +} + ########################## # Saved Recent Bug Lists # ########################## @@ -707,8 +756,8 @@ sub bless_groups { return $self->{'bless_groups'} if defined $self->{'bless_groups'}; return [] unless $self->id; - if ($self->in_group('editusers')) { - # Users having editusers permissions may bless all groups. + if ($self->in_group('admin')) { + # Users having admin permissions may bless all groups. $self->{'bless_groups'} = [Bugzilla::Group->get_all]; return $self->{'bless_groups'}; } @@ -778,6 +827,13 @@ sub in_group_id { return grep($_->id == $id, @{ $self->groups }) ? 1 : 0; } +# This is a helper to get all groups which have an icon to be displayed +# besides the name of the commenter. +sub groups_with_icon { + my $self = shift; + return $self->{groups_with_icon} //= [grep { $_->icon_url } @{ $self->direct_group_membership }]; +} + sub get_products_by_permission { my ($self, $group) = @_; # Make sure $group exists on a per-product basis. @@ -857,6 +913,14 @@ sub visible_bugs { if (@check_ids) { my $dbh = Bugzilla->dbh; my $user_id = $self->id; + + foreach my $id (@check_ids) { + my $orig_id = $id; + detaint_natural($id) + || ThrowCodeError('param_must_be_numeric', { param => $orig_id, + function => 'Bugzilla::User->visible_bugs'}); + } + my $sth; # Speed up the can_see_bug case. if (scalar(@check_ids) == 1) { @@ -1635,7 +1699,9 @@ our %names_to_events = ( 'attachments.mimetype' => EVT_ATTACHMENT_DATA, 'attachments.ispatch' => EVT_ATTACHMENT_DATA, 'dependson' => EVT_DEPEND_BLOCK, - 'blocked' => EVT_DEPEND_BLOCK); + 'blocked' => EVT_DEPEND_BLOCK, + 'product' => EVT_COMPONENT, + 'component' => EVT_COMPONENT); # Returns true if the user wants mail for a given bug change. # Note: the "+" signs before the constants suppress bareword quoting. @@ -1654,7 +1720,7 @@ sub wants_bug_mail { } else { # Catch-all for any change not caught by a more specific event - $events{+EVT_OTHER} = 1; + $events{+EVT_OTHER} = 1; } # If the user is in a particular role and the value of that role @@ -1810,6 +1876,17 @@ sub is_timetracker { return $self->{'is_timetracker'}; } +sub can_tag_comments { + my $self = shift; + + if (!defined $self->{'can_tag_comments'}) { + my $group = Bugzilla->params->{'comment_taggers_group'}; + $self->{'can_tag_comments'} = + ($group && $self->in_group($group)) ? 1 : 0; + } + return $self->{'can_tag_comments'}; +} + sub get_userlist { my $self = shift; @@ -2008,6 +2085,9 @@ sub check_and_send_account_creation_confirmation { ThrowUserError('account_creation_restricted'); } + # BMO - add a hook to allow extra validation prior to account creation. + Bugzilla::Hook::process("user_verify_login", { login => $login }); + # Create and send a token for this new account. require Bugzilla::Token; Bugzilla::Token::issue_new_user_account_token($login); @@ -2199,6 +2279,34 @@ groups. Returns a hashref with tag IDs as key, and a hashref with tag 'id', 'name' and 'bug_count' as value. +=item C<bugs_ignored> + +Returns an array of hashrefs containing information about bugs currently +being ignored by the user. + +Each hashref contains the following information: + +=over + +=item C<id> + +C<int> The id of the bug. + +=item C<status> + +C<string> The current status of the bug. + +=item C<summary> + +C<string> The current summary of the bug. + +=back + +=item C<is_bug_ignored> + +Returns true if the user does not want email notifications for the +specified bug ID, else returns false. + =back =head2 Saved Recent Bug Lists @@ -2363,7 +2471,7 @@ Determines whether or not a user is in the given group by id. Returns an arrayref of L<Bugzilla::Group> objects. The arrayref consists of the groups the user can bless, taking into account -that having editusers permissions means that you can bless all groups, and +that having admin permissions means that you can bless all groups, and that you need to be able to see a group in order to bless it. =item C<get_products_by_permission($group)> @@ -2569,6 +2677,12 @@ i.e. if the 'insidergroup' parameter is set and the user belongs to this group. Returns true if the user is a global watcher, i.e. if the 'globalwatchers' parameter contains the user. +=item C<can_tag_comments> + +Returns true if the user can attach tags to comments. +i.e. if the 'comment_taggers_group' parameter is set and the user belongs to +this group. + =back =head1 CLASS FUNCTIONS diff --git a/Bugzilla/UserAgent.pm b/Bugzilla/UserAgent.pm new file mode 100644 index 000000000..4b729a4ee --- /dev/null +++ b/Bugzilla/UserAgent.pm @@ -0,0 +1,257 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is the Mozilla Foundation +# Portions created by the Initial Developer are Copyright (C) 2011 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Terry Weissman <terry@mozilla.org> +# Dave Miller <justdave@syndicomm.com> +# Joe Robins <jmrobins@tgix.com> +# Gervase Markham <gerv@gerv.net> +# Shane H. W. Travis <travis@sedsystems.ca> +# Nitish Bezzala <nbezzala@yahoo.com> +# Byron Jones <glob@mozilla.com> + +package Bugzilla::UserAgent; + +use strict; +use base qw(Exporter); +our @EXPORT = qw(detect_platform detect_op_sys); + +use Bugzilla::Field; +use List::MoreUtils qw(natatime); + +use constant DEFAULT_VALUE => 'Other'; + +use constant PLATFORMS_MAP => ( + # PowerPC + qr/\(.*PowerPC.*\)/i => ["PowerPC", "Macintosh"], + # AMD64, Intel x86_64 + qr/\(.*[ix0-9]86 (?:on |\()x86_64.*\)/ => ["IA32", "x86", "PC"], + qr/\(.*amd64.*\)/ => ["AMD64", "x86_64", "PC"], + qr/\(.*x86_64.*\)/ => ["AMD64", "x86_64", "PC"], + # Intel IA64 + qr/\(.*IA64.*\)/ => ["IA64", "PC"], + # Intel x86 + qr/\(.*Intel.*\)/ => ["IA32", "x86", "PC"], + qr/\(.*[ix0-9]86.*\)/ => ["IA32", "x86", "PC"], + # Versions of Windows that only run on Intel x86 + qr/\(.*Win(?:dows |)[39M].*\)/ => ["IA32", "x86", "PC"], + qr/\(.*Win(?:dows |)16.*\)/ => ["IA32", "x86", "PC"], + # Sparc + qr/\(.*sparc.*\)/ => ["Sparc", "Sun"], + qr/\(.*sun4.*\)/ => ["Sparc", "Sun"], + # Alpha + qr/\(.*AXP.*\)/i => ["Alpha", "DEC"], + qr/\(.*[ _]Alpha.\D/i => ["Alpha", "DEC"], + qr/\(.*[ _]Alpha\)/i => ["Alpha", "DEC"], + # MIPS + qr/\(.*IRIX.*\)/i => ["MIPS", "SGI"], + qr/\(.*MIPS.*\)/i => ["MIPS", "SGI"], + # 68k + qr/\(.*68K.*\)/ => ["68k", "Macintosh"], + qr/\(.*680[x0]0.*\)/ => ["68k", "Macintosh"], + # HP + qr/\(.*9000.*\)/ => ["PA-RISC", "HP"], + # ARM + qr/\(.*(?:iPad|iPhone).*\)/ => ["ARM"], + qr/\(.*ARM.*\)/ => ["ARM", "PocketPC"], + # PocketPC intentionally before PowerPC + qr/\(.*Windows CE.*PPC.*\)/ => ["ARM", "PocketPC"], + # PowerPC + qr/\(.*PPC.*\)/ => ["PowerPC", "Macintosh"], + qr/\(.*AIX.*\)/ => ["PowerPC", "Macintosh"], + # Stereotypical and broken + qr/\(.*Windows CE.*\)/ => ["ARM", "PocketPC"], + qr/\(.*Macintosh.*\)/ => ["68k", "Macintosh"], + qr/\(.*Mac OS [89].*\)/ => ["68k", "Macintosh"], + qr/\(.*WOW64.*\)/ => ["x86_64"], + qr/\(.*Win64.*\)/ => ["IA64"], + qr/\(Win.*\)/ => ["IA32", "x86", "PC"], + qr/\(.*Win(?:dows[ -])NT.*\)/ => ["IA32", "x86", "PC"], + qr/\(.*OSF.*\)/ => ["Alpha", "DEC"], + qr/\(.*HP-?UX.*\)/i => ["PA-RISC", "HP"], + qr/\(.*IRIX.*\)/i => ["MIPS", "SGI"], + qr/\(.*(SunOS|Solaris).*\)/ => ["Sparc", "Sun"], + # Braindead old browsers who didn't follow convention: + qr/Amiga/ => ["68k", "Macintosh"], + qr/WinMosaic/ => ["IA32", "x86", "PC"], +); + +use constant OS_MAP => ( + # Sun + qr/\(.*Solaris.*\)/ => ["Solaris"], + qr/\(.*SunOS 5.11.*\)/ => [("OpenSolaris", "Opensolaris", "Solaris 11")], + qr/\(.*SunOS 5.10.*\)/ => ["Solaris 10"], + qr/\(.*SunOS 5.9.*\)/ => ["Solaris 9"], + qr/\(.*SunOS 5.8.*\)/ => ["Solaris 8"], + qr/\(.*SunOS 5.7.*\)/ => ["Solaris 7"], + qr/\(.*SunOS 5.6.*\)/ => ["Solaris 6"], + qr/\(.*SunOS 5.5.*\)/ => ["Solaris 5"], + qr/\(.*SunOS 5.*\)/ => ["Solaris"], + qr/\(.*SunOS.*sun4u.*\)/ => ["Solaris"], + qr/\(.*SunOS.*i86pc.*\)/ => ["Solaris"], + qr/\(.*SunOS.*\)/ => ["SunOS"], + # BSD + qr/\(.*BSD\/(?:OS|386).*\)/ => ["BSDI"], + qr/\(.*FreeBSD.*\)/ => ["FreeBSD"], + qr/\(.*OpenBSD.*\)/ => ["OpenBSD"], + qr/\(.*NetBSD.*\)/ => ["NetBSD"], + # Misc POSIX + qr/\(.*IRIX.*\)/ => ["IRIX"], + qr/\(.*OSF.*\)/ => ["OSF/1"], + qr/\(.*Linux.*\)/ => ["Linux"], + qr/\(.*BeOS.*\)/ => ["BeOS"], + qr/\(.*AIX.*\)/ => ["AIX"], + qr/\(.*OS\/2.*\)/ => ["OS/2"], + qr/\(.*QNX.*\)/ => ["Neutrino"], + qr/\(.*VMS.*\)/ => ["OpenVMS"], + qr/\(.*HP-?UX.*\)/ => ["HP-UX"], + qr/\(.*Android.*\)/ => ["Android"], + # Windows + qr/\(.*Windows XP.*\)/ => ["Windows XP"], + qr/\(.*Windows NT 6\.3.*\)/ => ["Windows 8.1"], + qr/\(.*Windows NT 6\.2.*\)/ => ["Windows 8"], + qr/\(.*Windows NT 6\.1.*\)/ => ["Windows 7"], + qr/\(.*Windows NT 6\.0.*\)/ => ["Windows Vista"], + qr/\(.*Windows NT 5\.2.*\)/ => ["Windows Server 2003"], + qr/\(.*Windows NT 5\.1.*\)/ => ["Windows XP"], + qr/\(.*Windows 2000.*\)/ => ["Windows 2000"], + qr/\(.*Windows NT 5.*\)/ => ["Windows 2000"], + qr/\(.*Win.*9[8x].*4\.9.*\)/ => ["Windows ME"], + qr/\(.*Win(?:dows |)M[Ee].*\)/ => ["Windows ME"], + qr/\(.*Win(?:dows |)98.*\)/ => ["Windows 98"], + qr/\(.*Win(?:dows |)95.*\)/ => ["Windows 95"], + qr/\(.*Win(?:dows |)16.*\)/ => ["Windows 3.1"], + qr/\(.*Win(?:dows[ -]|)NT.*\)/ => ["Windows NT"], + qr/\(.*Windows.*NT.*\)/ => ["Windows NT"], + # OS X + qr/\(.*(?:iPad|iPhone).*OS 7.*\)/ => ["iOS 7"], + qr/\(.*(?:iPad|iPhone).*OS 6.*\)/ => ["iOS 6"], + qr/\(.*(?:iPad|iPhone).*OS 5.*\)/ => ["iOS 5"], + qr/\(.*(?:iPad|iPhone).*OS 4.*\)/ => ["iOS 4"], + qr/\(.*(?:iPad|iPhone).*OS 3.*\)/ => ["iOS 3"], + qr/\(.*(?:iPad|iPhone).*\)/ => ["iOS"], + qr/\(.*Mac OS X (?:|Mach-O |\()10.6.*\)/ => ["Mac OS X 10.6"], + qr/\(.*Mac OS X (?:|Mach-O |\()10.5.*\)/ => ["Mac OS X 10.5"], + qr/\(.*Mac OS X (?:|Mach-O |\()10.4.*\)/ => ["Mac OS X 10.4"], + qr/\(.*Mac OS X (?:|Mach-O |\()10.3.*\)/ => ["Mac OS X 10.3"], + qr/\(.*Mac OS X (?:|Mach-O |\()10.2.*\)/ => ["Mac OS X 10.2"], + qr/\(.*Mac OS X (?:|Mach-O |\()10.1.*\)/ => ["Mac OS X 10.1"], + # Unfortunately, OS X 10.4 was the first to support Intel. This is fallback + # support because some browsers refused to include the OS Version. + qr/\(.*Intel.*Mac OS X.*\)/ => ["Mac OS X 10.4"], + # OS X 10.3 is the most likely default version of PowerPC Macs + # OS X 10.0 is more for configurations which didn't setup 10.x versions + qr/\(.*Mac OS X.*\)/ => [("Mac OS X 10.3", "Mac OS X 10.0", "Mac OS X")], + qr/\(.*Mac OS 9.*\)/ => [("Mac System 9.x", "Mac System 9.0")], + qr/\(.*Mac OS 8\.6.*\)/ => [("Mac System 8.6", "Mac System 8.5")], + qr/\(.*Mac OS 8\.5.*\)/ => ["Mac System 8.5"], + qr/\(.*Mac OS 8\.1.*\)/ => [("Mac System 8.1", "Mac System 8.0")], + qr/\(.*Mac OS 8\.0.*\)/ => ["Mac System 8.0"], + qr/\(.*Mac OS 8[^.].*\)/ => ["Mac System 8.0"], + qr/\(.*Mac OS 8.*\)/ => ["Mac System 8.6"], + qr/\(.*Darwin.*\)/ => [("Mac OS X 10.0", "Mac OS X")], + # Silly + qr/\(.*Mac.*PowerPC.*\)/ => ["Mac System 9.x"], + qr/\(.*Mac.*PPC.*\)/ => ["Mac System 9.x"], + qr/\(.*Mac.*68k.*\)/ => ["Mac System 8.0"], + # Evil + qr/Amiga/i => ["Other"], + qr/WinMosaic/ => ["Windows 95"], + qr/\(.*32bit.*\)/ => ["Windows 95"], + qr/\(.*16bit.*\)/ => ["Windows 3.1"], + qr/\(.*PowerPC.*\)/ => ["Mac System 9.x"], + qr/\(.*PPC.*\)/ => ["Mac System 9.x"], + qr/\(.*68K.*\)/ => ["Mac System 8.0"], +); + +sub detect_platform { + my $userAgent = $ENV{'HTTP_USER_AGENT'} || ''; + my @detected; + my $iterator = natatime(2, PLATFORMS_MAP); + while (my($re, $ra) = $iterator->()) { + if ($userAgent =~ $re) { + push @detected, @$ra; + } + } + return _pick_valid_field_value('rep_platform', @detected); +} + +sub detect_op_sys { + my $userAgent = $ENV{'HTTP_USER_AGENT'} || ''; + my @detected; + my $iterator = natatime(2, OS_MAP); + while (my($re, $ra) = $iterator->()) { + if ($userAgent =~ $re) { + push @detected, @$ra; + } + } + push(@detected, "Windows") if grep(/^Windows /, @detected); + push(@detected, "Mac OS") if grep(/^Mac /, @detected); + return _pick_valid_field_value('op_sys', @detected); +} + +# Takes the name of a field and a list of possible values for that field. +# Returns the first value in the list that is actually a valid value for that +# field. +# Returns 'Other' if none of the values match. +sub _pick_valid_field_value { + my ($field, @values) = @_; + foreach my $value (@values) { + return $value if check_field($field, $value, undef, 1); + } + return DEFAULT_VALUE; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::UserAgent - UserAgent utilities for Bugzilla + +=head1 SYNOPSIS + + use Bugzilla::UserAgent; + printf "platform: %s op-sys: %s\n", detect_platform(), detect_op_sys(); + +=head1 DESCRIPTION + +The functions exported by this module all return information derived from the +remote client's user agent. + +=head1 FUNCTIONS + +=over 4 + +=item C<detect_platform> + +This function attempts to detect the remote client's platform from the +presented user-agent. If a suitable value on the I<platform> field is found, +that field value will be returned. If no suitable value is detected, +C<detect_platform> returns I<Other>. + +=item C<detect_op_sys> + +This function attempts to detect the remote client's operating system from the +presented user-agent. If a suitable value on the I<op_sys> field is found, that +field value will be returned. If no suitable value is detected, +C<detect_op_sys> returns I<Other>. + +=back + diff --git a/Bugzilla/Util.pm b/Bugzilla/Util.pm index c2dbdc97d..a030a92dd 100644 --- a/Bugzilla/Util.pm +++ b/Bugzilla/Util.pm @@ -28,6 +28,7 @@ package Bugzilla::Util; +use 5.10.1; use strict; use base qw(Exporter); @@ -35,7 +36,7 @@ use base qw(Exporter); detaint_signed html_quote url_quote xml_quote css_class_quote html_light_quote - i_am_cgi correct_urlbase remote_ip validate_ip + i_am_cgi i_am_webservice correct_urlbase remote_ip validate_ip do_ssl_redirect_if_required use_attachbase diff_arrays on_main_db trim wrap_hard wrap_comment find_wrap_point @@ -44,7 +45,7 @@ use base qw(Exporter); bz_crypt generate_random_password validate_email_syntax clean_text get_text template_var disable_utf8 - detect_encoding); + detect_encoding email_filter); use Bugzilla::Constants; use Bugzilla::RNG qw(irand); @@ -57,7 +58,6 @@ use Digest; use Email::Address; use List::Util qw(first); use Scalar::Util qw(tainted blessed); -use Template::Filters; use Text::Wrap; use Encode qw(encode decode resolve_alias); use Encode::Guess; @@ -87,10 +87,17 @@ sub detaint_signed { # visible strings. # Bug 319331: Handle BiDi disruptions. sub html_quote { - my ($var) = Template::Filters::html_filter(@_); + my $var = shift; + $var =~ s/&/&/g; + $var =~ s/</</g; + $var =~ s/>/>/g; + $var =~ s/"/"/g; # Obscure '@'. $var =~ s/\@/\@/g; - if (Bugzilla->params->{'utf8'}) { + + state $use_utf8 = Bugzilla->params->{'utf8'}; + + if ($use_utf8) { # Remove the following characters because they're # influencing BiDi: # -------------------------------------------------------- @@ -112,13 +119,16 @@ sub html_quote { # |U+200e|Left-To-Right Mark |0xe2 0x80 0x8e | # |U+200f|Right-To-Left Mark |0xe2 0x80 0x8f | # -------------------------------------------------------- - $var =~ s/[\x{202a}-\x{202e}]//g; + $var =~ tr/\x{202a}-\x{202e}//d; } return $var; } sub html_light_quote { my ($text) = @_; + # admin/table.html.tmpl calls |FILTER html_light| many times. + # There is no need to recreate the HTML::Scrubber object again and again. + my $scrubber = Bugzilla->process_cache->{html_scrubber}; # List of allowed HTML elements having no attributes. my @allow = qw(b strong em i u p br abbr acronym ins del cite code var @@ -140,7 +150,7 @@ sub html_light_quote { $text =~ s#$chr($safe)$chr#<$1>#go; return $text; } - else { + elsif (!$scrubber) { # We can be less restrictive. We can accept elements with attributes. push(@allow, qw(a blockquote q span)); @@ -183,14 +193,14 @@ sub html_light_quote { }, ); - my $scrubber = HTML::Scrubber->new(default => \@default, - allow => \@allow, - rules => \@rules, - comment => 0, - process => 0); - - return $scrubber->scrub($text); + Bugzilla->process_cache->{html_scrubber} = $scrubber = + HTML::Scrubber->new(default => \@default, + allow => \@allow, + rules => \@rules, + comment => 0, + process => 0); } + return $scrubber->scrub($text); } sub email_filter { @@ -249,6 +259,13 @@ sub i_am_cgi { return exists $ENV{'SERVER_SOFTWARE'} ? 1 : 0; } +sub i_am_webservice { + my $usage_mode = Bugzilla->usage_mode; + return $usage_mode == USAGE_MODE_XMLRPC + || $usage_mode == USAGE_MODE_JSON + || $usage_mode == USAGE_MODE_REST; +} + # This exists as a separate function from Bugzilla::CGI::redirect_to_https # because we don't want to create a CGI object during XML-RPC calls # (doing so can mess up XML-RPC). @@ -471,11 +488,11 @@ sub find_wrap_point { if (!$string) { return 0 } if (length($string) < $maxpos) { return length($string) } my $wrappoint = rindex($string, ",", $maxpos); # look for comma - if ($wrappoint < 0) { # can't find comma + if ($wrappoint <= 0) { # can't find comma $wrappoint = rindex($string, " ", $maxpos); # look for space - if ($wrappoint < 0) { # can't find space + if ($wrappoint <= 0) { # can't find space $wrappoint = rindex($string, "-", $maxpos); # look for hyphen - if ($wrappoint < 0) { # can't find hyphen + if ($wrappoint <= 0) { # can't find hyphen $wrappoint = $maxpos; # just truncate it } else { $wrappoint++; # leave hyphen on the left side @@ -726,10 +743,12 @@ sub get_text { sub template_var { my $name = shift; - my $cache = Bugzilla->request_cache->{util_template_var} ||= {}; - my $template = Bugzilla->template_inner; - my $lang = $template->context->{bz_language}; + my $request_cache = Bugzilla->request_cache; + my $cache = $request_cache->{util_template_var} ||= {}; + my $lang = $request_cache->{template_current_lang}->[0]; return $cache->{$lang}->{$name} if defined $cache->{$lang}; + + my $template = Bugzilla->template_inner($lang); my %vars; # Note: If we suddenly start needing a lot of template_var variables, # they should move into their own template, not field-descs. @@ -746,11 +765,7 @@ sub template_var { sub display_value { my ($field, $value) = @_; - my $value_descs = template_var('value_descs'); - if (defined $value_descs->{$field}->{$value}) { - return $value_descs->{$field}->{$value}; - } - return $value; + return template_var('value_descs')->{$field}->{$value} // $value; } sub disable_utf8 { diff --git a/Bugzilla/WebService.pm b/Bugzilla/WebService.pm index 166707626..60642c5e8 100644 --- a/Bugzilla/WebService.pm +++ b/Bugzilla/WebService.pm @@ -52,15 +52,20 @@ This is the standard API for external programs that want to interact with Bugzilla. It provides various methods in various modules. You can interact with this API via -L<XML-RPC|Bugzilla::WebService::Server::XMLRPC> or -L<JSON-RPC|Bugzilla::WebService::Server::JSONRPC>. +L<XML-RPC|Bugzilla::WebService::Server::XMLRPC>, +L<JSON-RPC|Bugzilla::WebService::Server::JSONRPC> or +L<REST|Bugzilla::WebService::Server::REST>. =head1 CALLING METHODS -Methods are grouped into "packages", like C<Bug> for +Methods are grouped into "packages", like C<Bug> for L<Bugzilla::WebService::Bug>. So, for example, L<Bugzilla::WebService::Bug/get>, is called as C<Bug.get>. +For REST, the "package" is more determined by the path +used to access the resource. See each relevant method +for specific details on how to access via REST. + =head1 PARAMETERS The Bugzilla API takes the following various types of parameters: @@ -79,6 +84,11 @@ A floating-point number. May be null. A string. May be null. +=item C<email> + +A string representing an email address. This value, when returned, +may be filtered based on if the user is logged in or not. May be null. + =item C<dateTime> A date/time. Represented differently in different interfaces to this API. @@ -137,7 +147,7 @@ There are various ways to log in: =item C<User.login> -You can use L<Bugzilla::WebService::User/login> to log in as a Bugzilla +You can use L<Bugzilla::WebService::User/login> to log in as a Bugzilla user. This issues standard HTTP cookies that you must then use in future calls, so your client must be capable of receiving and transmitting cookies. @@ -167,13 +177,24 @@ not expire. =back The C<Bugzilla_restrictlogin> and C<Bugzilla_rememberlogin> options -are only used when you have also specified C<Bugzilla_login> and +are only used when you have also specified C<Bugzilla_login> and C<Bugzilla_password>. Note that Bugzilla will return HTTP cookies along with the method response when you use these arguments (just like the C<User.login> method above). +For REST, you may also use the C<username> and C<password> variable +names instead of C<Bugzilla_login> and C<Bugzilla_password> as a +convenience. + +=item B<Added in Bugzilla 5.0> + +An error is now thrown if you pass invalid cookies or an invalid token. +You will need to log in again to get new cookies or a new token. Previous +releases simply ignored invalid cookies and token support was added in +Bugzilla B<5.0>. + =back =head1 STABLE, EXPERIMENTAL, and UNSTABLE @@ -258,6 +279,14 @@ be returned, the rest will not be included. If you specify an empty array, then this function will return empty hashes. +Some RPC calls support specifying sub fields. + +If an RPC call states that it support sub field restrictions, you can +restrict what information is returned within the first field. For example, +if you call Products.get with an include_fields of components.name, then +only the component name would be returned (and nothing else). You can +include the main field, and exclude a sub field. + Invalid field names are ignored. Example: @@ -268,6 +297,9 @@ would return something like: { users => [{ id => 1, name => 'user@domain.com' }] } +Note for REST, C<include_fields> may instead be a comma delimited string +for GET type requests. + =item C<exclude_fields> C<array> An array of strings, representing the (case-sensitive) names of @@ -277,6 +309,13 @@ the returned hashes. If you specify all the fields, then this function will return empty hashes. +Some RPC calls support specifying sub fields. If an RPC call states that +it support sub field restrictions, you can restrict what information is +returned within the first field. For example, if you call Products.get +with an include_fields of components.name, then only the component name +would be returned (and nothing else). You can include the main field, +and exclude a sub field. + Invalid field names are ignored. Specifying fields here overrides C<include_fields>, so if you specify a @@ -290,6 +329,9 @@ would return something like: { users => [{ id => 1, real_name => 'John Smith' }] } +Note for REST, C<exclude_fields> may instead be a comma delimited string +for GET type requests. + =back =head1 SEE ALSO diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm index eb76b4131..fbf084862 100644 --- a/Bugzilla/WebService/Bug.pm +++ b/Bugzilla/WebService/Bug.pm @@ -26,18 +26,25 @@ use strict; use base qw(Bugzilla::WebService); use Bugzilla::Comment; +use Bugzilla::Comment::TagWeights; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Field; use Bugzilla::WebService::Constants; -use Bugzilla::WebService::Util qw(filter filter_wants validate); +use Bugzilla::WebService::Util qw(filter filter_wants validate translate); use Bugzilla::Bug; use Bugzilla::BugMail; -use Bugzilla::Util qw(trick_taint trim); +use Bugzilla::Util qw(trick_taint trim detaint_natural); use Bugzilla::Version; use Bugzilla::Milestone; use Bugzilla::Status; use Bugzilla::Token qw(issue_hash_token); +use Bugzilla::Search; +use Bugzilla::Search::Quicksearch; + +use List::Util qw(max); +use List::MoreUtils qw(uniq); +use Storable qw(dclone); ############# # Constants # @@ -47,6 +54,7 @@ use constant PRODUCT_SPECIFIC_FIELDS => qw(version target_milestone component); use constant DATE_FIELDS => { comments => ['new_since'], + history => ['new_since'], search => ['last_change_time', 'creation_time'], }; @@ -64,6 +72,20 @@ use constant READ_ONLY => qw( search ); +use constant ATTACHMENT_MAPPED_SETTERS => { + file_name => 'filename', + summary => 'description', +}; + +use constant ATTACHMENT_MAPPED_RETURNS => { + description => 'summary', + ispatch => 'is_patch', + isprivate => 'is_private', + isobsolete => 'is_obsolete', + filename => 'file_name', + mimetype => 'content_type', +}; + ###################################################### # Add aliases here for old method name compatibility # ###################################################### @@ -82,6 +104,8 @@ BEGIN { sub fields { my ($self, $params) = validate(@_, 'ids', 'names'); + Bugzilla->switch_to_shadow_db(); + my @fields; if (defined $params->{ids}) { my $ids = $params->{ids}; @@ -117,11 +141,12 @@ sub fields { my (@values, $has_values); if ( ($field->is_select and $field->name ne 'product') - or grep($_ eq $field->name, PRODUCT_SPECIFIC_FIELDS)) + or grep($_ eq $field->name, PRODUCT_SPECIFIC_FIELDS) + or $field->name eq 'keywords') { $has_values = 1; @values = @{ $self->_legal_field_values({ field => $field }) }; - } + } if (grep($_ eq $field->name, PRODUCT_SPECIFIC_FIELDS)) { $value_field = 'product'; @@ -211,6 +236,15 @@ sub _legal_field_values { } } + elsif ($field_name eq 'keywords') { + my @legal_keywords = Bugzilla::Keyword->get_all; + foreach my $value (@legal_keywords) { + push (@result, { + name => $self->type('string', $value->name), + description => $self->type('string', $value->description), + }); + } + } else { my @values = Bugzilla::Field::Choice->type($field)->get_all(); foreach my $value (@values) { @@ -242,7 +276,7 @@ sub comments { my $bug_ids = $params->{ids} || []; my $comment_ids = $params->{comment_ids} || []; - my $dbh = Bugzilla->dbh; + my $dbh = Bugzilla->switch_to_shadow_db(); my $user = Bugzilla->user; my %bugs; @@ -294,26 +328,46 @@ sub _translate_comment { my ($self, $comment, $filters) = @_; my $attach_id = $comment->is_about_attachment ? $comment->extra_data : undef; - return filter $filters, { + + my $comment_hash = { id => $self->type('int', $comment->id), bug_id => $self->type('int', $comment->bug_id), - creator => $self->type('string', $comment->author->login), - author => $self->type('string', $comment->author->login), + creator => $self->type('email', $comment->author->login), + author => $self->type('email', $comment->author->login), time => $self->type('dateTime', $comment->creation_ts), + creation_time => $self->type('dateTime', $comment->creation_ts), is_private => $self->type('boolean', $comment->is_private), text => $self->type('string', $comment->body_full), attachment_id => $self->type('int', $attach_id), }; + + # Don't load comment tags unless enabled + if (Bugzilla->params->{'comment_taggers_group'}) { + $comment_hash->{tags} = [ + map { $self->type('string', $_) } + @{ $comment->tags } + ]; + } + + return filter $filters, $comment_hash; } sub get { my ($self, $params) = validate(@_, 'ids'); + Bugzilla->switch_to_shadow_db(); + my $ids = $params->{ids}; - defined $ids || ThrowCodeError('param_required', { param => 'ids' }); + (defined $ids && scalar @$ids) + || ThrowCodeError('param_required', { param => 'ids' }); + + my (@bugs, @faults, @hashes); + + # Cache permissions for bugs. This highly reduces the number of calls to the DB. + # visible_bugs() is only able to handle bug IDs, so we have to skip aliases. + my @int = grep { $_ =~ /^\d+$/ } @$ids; + Bugzilla->user->visible_bugs(\@int); - my @bugs; - my @faults; foreach my $bug_id (@$ids) { my $bug; if ($params->{permissive}) { @@ -331,10 +385,18 @@ sub get { else { $bug = Bugzilla::Bug->check($bug_id); } - push(@bugs, $self->_bug_to_hash($bug, $params)); + push(@bugs, $bug); + push(@hashes, $self->_bug_to_hash($bug, $params)); } - return { bugs => \@bugs, faults => \@faults }; + # Set the ETag before inserting the update tokens + # since the tokens will always be unique even if + # the data has not changed. + $self->bz_etag(\@bugs); + + $self->_add_update_tokens($params, \@bugs, \@hashes); + + return { bugs => \@hashes, faults => \@faults }; } # this is a function that gets bug activity for list of bug ids @@ -343,34 +405,39 @@ sub get { sub history { my ($self, $params) = validate(@_, 'ids'); + Bugzilla->switch_to_shadow_db(); + my $ids = $params->{ids}; defined $ids || ThrowCodeError('param_required', { param => 'ids' }); - my @return; + my %api_name = reverse %{ Bugzilla::Bug::FIELD_MAP() }; + $api_name{'bug_group'} = 'groups'; + my @return; foreach my $bug_id (@$ids) { my %item; my $bug = Bugzilla::Bug->check($bug_id); $bug_id = $bug->id; $item{id} = $self->type('int', $bug_id); - my ($activity) = Bugzilla::Bug::GetBugActivity($bug_id); + my ($activity) = Bugzilla::Bug::GetBugActivity($bug_id, undef, $params->{new_since}); my @history; foreach my $changeset (@$activity) { my %bug_history; $bug_history{when} = $self->type('dateTime', $changeset->{when}); - $bug_history{who} = $self->type('string', $changeset->{who}); + $bug_history{who} = $self->type('email', $changeset->{who}); $bug_history{changes} = []; foreach my $change (@{ $changeset->{changes} }) { + my $api_field = $api_name{$change->{fieldname}} || $change->{fieldname}; my $attach_id = delete $change->{attachid}; if ($attach_id) { $change->{attachment_id} = $self->type('int', $attach_id); } $change->{removed} = $self->type('string', $change->{removed}); $change->{added} = $self->type('string', $change->{added}); - $change->{field_name} = $self->type('string', - delete $change->{fieldname}); + $change->{field_name} = $self->type('string', $api_field); + delete $change->{fieldname}; push (@{$bug_history{changes}}, $change); } @@ -399,77 +466,117 @@ sub history { sub search { my ($self, $params) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + + Bugzilla->switch_to_shadow_db(); + + my $match_params = dclone($params); + delete $match_params->{include_fields}; + delete $match_params->{exclude_fields}; + + # Determine whether this is a quicksearch query + if (exists $match_params->{quicksearch}) { + my $quicksearch = quicksearch($match_params->{'quicksearch'}); + my $cgi = Bugzilla::CGI->new($quicksearch); + $match_params = $cgi->Vars; + } - if ( defined($params->{offset}) and !defined($params->{limit}) ) { - ThrowCodeError('param_required', + if ( defined($match_params->{offset}) and !defined($match_params->{limit}) ) { + ThrowCodeError('param_required', { param => 'limit', function => 'Bug.search()' }); } my $max_results = Bugzilla->params->{max_search_results}; - unless (defined $params->{limit} && $params->{limit} == 0) { - if (!defined $params->{limit} || $params->{limit} > $max_results) { - $params->{limit} = $max_results; + unless (defined $match_params->{limit} && $match_params->{limit} == 0) { + if (!defined $match_params->{limit} || $match_params->{limit} > $max_results) { + $match_params->{limit} = $max_results; } } else { - delete $params->{limit}; - delete $params->{offset}; + delete $match_params->{limit}; + delete $match_params->{offset}; } - $params = Bugzilla::Bug::map_fields($params); - delete $params->{WHERE}; + $match_params = Bugzilla::Bug::map_fields($match_params); - unless (Bugzilla->user->is_timetracker) { - delete $params->{$_} foreach qw(estimated_time remaining_time deadline); - } + my %options = ( fields => ['bug_id'] ); + + # Find the highest custom field id + my @field_ids = grep(/^f(\d+)$/, keys %$match_params); + my $last_field_id = @field_ids ? max @field_ids + 1 : 1; # Do special search types for certain fields. - if ( my $bug_when = delete $params->{delta_ts} ) { - $params->{WHERE}->{'delta_ts >= ?'} = $bug_when; + if (my $change_when = delete $match_params->{'delta_ts'}) { + $match_params->{"f${last_field_id}"} = 'delta_ts'; + $match_params->{"o${last_field_id}"} = 'greaterthaneq'; + $match_params->{"v${last_field_id}"} = $change_when; + $last_field_id++; } - if (my $when = delete $params->{creation_ts}) { - $params->{WHERE}->{'creation_ts >= ?'} = $when; + if (my $creation_when = delete $match_params->{'creation_ts'}) { + $match_params->{"f${last_field_id}"} = 'creation_ts'; + $match_params->{"o${last_field_id}"} = 'greaterthaneq'; + $match_params->{"v${last_field_id}"} = $creation_when; + $last_field_id++; + } + + # Some fields require a search type such as short desc, keywords, etc. + foreach my $param (qw(short_desc longdesc status_whiteboard bug_file_loc)) { + if (defined $match_params->{$param} && !defined $match_params->{$param . '_type'}) { + $match_params->{$param . '_type'} = 'allwordssubstr'; + } } - if (my $summary = delete $params->{short_desc}) { - my @strings = ref $summary ? @$summary : ($summary); - my @likes = ("short_desc LIKE ?") x @strings; - my $clause = join(' OR ', @likes); - $params->{WHERE}->{"($clause)"} = [map { "\%$_\%" } @strings]; + if (defined $match_params->{'keywords'} && !defined $match_params->{'keywords_type'}) { + $match_params->{'keywords_type'} = 'allwords'; } - if (my $whiteboard = delete $params->{status_whiteboard}) { - my @strings = ref $whiteboard ? @$whiteboard : ($whiteboard); - my @likes = ("status_whiteboard LIKE ?") x @strings; - my $clause = join(' OR ', @likes); - $params->{WHERE}->{"($clause)"} = [map { "\%$_\%" } @strings]; + + # Backwards compatibility with old method regarding role search + $match_params->{'reporter'} = delete $match_params->{'creator'} if $match_params->{'creator'}; + foreach my $role (qw(assigned_to reporter qa_contact longdesc cc)) { + next if !exists $match_params->{$role}; + my $value = delete $match_params->{$role}; + $match_params->{"f${last_field_id}"} = $role; + $match_params->{"o${last_field_id}"} = "anywordssubstr"; + $match_params->{"v${last_field_id}"} = ref $value ? join(" ", @{$value}) : $value; + $last_field_id++; } # If no other parameters have been passed other than limit and offset - # and a WHERE parameter was not created earlier, then we throw error - # if system is configured to do so. - if (!$params->{WHERE} - && !grep(!/(limit|offset)/i, keys %$params) + # then we throw error if system is configured to do so. + if (!grep(!/^(limit|offset)$/, keys %$match_params) && !Bugzilla->params->{search_allow_no_criteria}) { ThrowUserError('buglist_parameters_required'); } - # We want include_fields and exclude_fields to be passed to - # _bug_to_hash but not to Bugzilla::Bug->match so we copy the - # params and delete those before passing to Bugzilla::Bug->match. - my %match_params = %{ $params }; - delete $match_params{'include_fields'}; - delete $match_params{'exclude_fields'}; + $options{order} = [ split(/\s*,\s*/, delete $match_params->{order}) ] if $match_params->{order}; + $options{params} = $match_params; - my $bugs = Bugzilla::Bug->match(\%match_params); - my $visible = Bugzilla->user->visible_bugs($bugs); - my @hashes = map { $self->_bug_to_hash($_, $params) } @$visible; - return { bugs => \@hashes }; + my $search = new Bugzilla::Search(%options); + my ($data) = $search->data; + + # BMO if the caller only wants the count, that's all we need to return + return $data if $params->{count_only}; + + if (!scalar @$data) { + return { bugs => [] }; + } + + # Search.pm won't return bugs that the user shouldn't see so no filtering is needed. + my @bug_ids = map { $_->[0] } @$data; + my %bug_objects = map { $_->id => $_ } @{ Bugzilla::Bug->new_from_list(\@bug_ids) }; + my @bugs = map { $bug_objects{$_} } @bug_ids; + @bugs = map { $self->_bug_to_hash($_, $params) } @bugs; + + return { bugs => \@bugs }; } sub possible_duplicates { my ($self, $params) = validate(@_, 'product'); my $user = Bugzilla->user; + Bugzilla->switch_to_shadow_db(); + # Undo the array-ification that validate() does, for "summary". $params->{summary} || ThrowCodeError('param_required', { function => 'Bug.possible_duplicates', param => 'summary' }); @@ -484,12 +591,19 @@ sub possible_duplicates { { summary => $params->{summary}, products => \@products, limit => $params->{limit} }); my @hashes = map { $self->_bug_to_hash($_, $params) } @$possible_dupes; + $self->_add_update_tokens($params, $possible_dupes, \@hashes); return { bugs => \@hashes }; } sub update { my ($self, $params) = validate(@_, 'ids'); + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage('bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.'); + } + my $user = Bugzilla->login(LOGIN_REQUIRED); my $dbh = Bugzilla->dbh; @@ -584,7 +698,20 @@ sub update { sub create { my ($self, $params) = @_; + + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage('bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.'); + } + Bugzilla->login(LOGIN_REQUIRED); + + # Some fields cannot be sent to Bugzilla::Bug->create + foreach my $key (qw(login password token)) { + delete $params->{$key}; + } + $params = Bugzilla::Bug::map_fields($params); my $bug = Bugzilla::Bug->create($params); Bugzilla::BugMail::Send($bug->bug_id, { changer => $bug->reporter }); @@ -594,6 +721,8 @@ sub create { sub legal_values { my ($self, $params) = @_; + Bugzilla->switch_to_shadow_db(); + defined $params->{field} or ThrowCodeError('param_required', { param => 'field' }); @@ -646,6 +775,12 @@ sub add_attachment { my ($self, $params) = validate(@_, 'ids'); my $dbh = Bugzilla->dbh; + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage('bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.'); + } + Bugzilla->login(LOGIN_REQUIRED); defined $params->{ids} || ThrowCodeError('param_required', { param => 'ids' }); @@ -691,9 +826,95 @@ sub add_attachment { return { attachments => \%attachments }; } +sub update_attachment { + my ($self, $params) = validate(@_, 'ids'); + + my $user = Bugzilla->login(LOGIN_REQUIRED); + my $dbh = Bugzilla->dbh; + + my $ids = delete $params->{ids}; + defined $ids || ThrowCodeError('param_required', { param => 'ids' }); + + # Some fields cannot be sent to set_all + foreach my $key (qw(login password token)) { + delete $params->{$key}; + } + + # We can't update flags, and summary is really description + delete $params->{flags}; + + $params = translate($params, ATTACHMENT_MAPPED_SETTERS); + + # Get all the attachments, after verifying that they exist and are editable + my @attachments = (); + my %bugs = (); + foreach my $id (@$ids) { + my $attachment = Bugzilla::Attachment->new($id) + || ThrowUserError("invalid_attach_id", { attach_id => $id }); + my $bug = $attachment->bug; + $attachment->_check_bug; + $attachment->validate_can_edit($bug->product_id) + || ThrowUserError("illegal_attachment_edit", { attach_id => $id }); + + push @attachments, $attachment; + $bugs{$bug->id} = $bug; + } + + # Update the values + foreach my $attachment (@attachments) { + $attachment->set_all($params); + } + + $dbh->bz_start_transaction(); + + # Do the actual update and get information to return to user + my @result; + foreach my $attachment (@attachments) { + my $changes = $attachment->update(); + + $changes = translate($changes, ATTACHMENT_MAPPED_RETURNS); + + my %hash = ( + id => $self->type('int', $attachment->id), + last_change_time => $self->type('dateTime', $attachment->modification_time), + changes => {}, + ); + + foreach my $field (keys %$changes) { + my $change = $changes->{$field}; + + # We normalize undef to an empty string, so that the API + # stays consistent for things like Deadline that can become + # empty. + $hash{changes}->{$field} = { + removed => $self->type('string', $change->[0] // ''), + added => $self->type('string', $change->[1] // '') + }; + } + + push(@result, \%hash); + } + + $dbh->bz_commit_transaction(); + + # Email users about the change + foreach my $bug (values %bugs) { + Bugzilla::BugMail::Send($bug->id, { 'changer' => $user }); + } + + # Return the information to the user + return { attachments => \@result }; +} + sub add_comment { my ($self, $params) = @_; + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage('bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.'); + } + #The user must login in order add a comment Bugzilla->login(LOGIN_REQUIRED); @@ -738,6 +959,12 @@ sub add_comment { sub update_see_also { my ($self, $params) = @_; + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage('bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.'); + } + my $user = Bugzilla->login(LOGIN_REQUIRED); # Check parameters @@ -785,6 +1012,8 @@ sub update_see_also { sub attachments { my ($self, $params) = validate(@_, 'ids', 'attachment_ids'); + Bugzilla->switch_to_shadow_db(); + if (!(defined $params->{ids} or defined $params->{attachment_ids})) { @@ -821,6 +1050,70 @@ sub attachments { return { bugs => \%bugs, attachments => \%attachments }; } +sub update_comment_tags { + my ($self, $params) = @_; + + my $user = Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->params->{'comment_taggers_group'} + || ThrowUserError("comment_tag_disabled"); + $user->can_tag_comments + || ThrowUserError("auth_failure", + { group => Bugzilla->params->{'comment_taggers_group'}, + action => "update", + object => "comment_tags" }); + + my $comment_id = $params->{comment_id} + // ThrowCodeError('param_required', + { function => 'Bug.update_comment_tags', + param => 'comment_id' }); + + my $comment = Bugzilla::Comment->new($comment_id) + || return []; + $comment->bug->check_is_visible(); + if ($comment->is_private && !$user->is_insider) { + ThrowUserError('comment_is_private', { id => $comment_id }); + } + + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + foreach my $tag (@{ $params->{add} || [] }) { + $comment->add_tag($tag) if defined $tag; + } + foreach my $tag (@{ $params->{remove} || [] }) { + $comment->remove_tag($tag) if defined $tag; + } + $comment->update(); + $dbh->bz_commit_transaction(); + + return $comment->tags; +} + +sub search_comment_tags { + my ($self, $params) = @_; + + Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->params->{'comment_taggers_group'} + || ThrowUserError("comment_tag_disabled"); + Bugzilla->user->can_tag_comments + || ThrowUserError("auth_failure", { group => Bugzilla->params->{'comment_taggers_group'}, + action => "search", + object => "comment_tags"}); + + my $query = $params->{query}; + $query + // ThrowCodeError('param_required', { param => 'query' }); + my $limit = detaint_natural($params->{limit}) || 7; + + my $tags = Bugzilla::Comment::TagWeights->match({ + WHERE => { + 'tag LIKE ?' => "\%$query\%", + }, + LIMIT => $limit, + }); + return [ map { $_->tag } @$tags ]; +} + + ############################## # Private Helper Subroutines # ############################## @@ -863,18 +1156,21 @@ sub _bug_to_hash { # We don't do the SQL calls at all if the filter would just # eliminate them anyway. if (filter_wants $params, 'assigned_to') { - $item{'assigned_to'} = $self->type('string', $bug->assigned_to->login); + $item{'assigned_to'} = $self->type('email', $bug->assigned_to->login); + $item{'assigned_to_detail'} = $self->_user_to_hash($bug->assigned_to, $params, 'assigned_to'); } if (filter_wants $params, 'blocks') { my @blocks = map { $self->type('int', $_) } @{ $bug->blocked }; $item{'blocks'} = \@blocks; } if (filter_wants $params, 'cc') { - my @cc = map { $self->type('string', $_) } @{ $bug->cc || [] }; + my @cc = map { $self->type('email', $_) } @{ $bug->cc || [] }; $item{'cc'} = \@cc; + $item{'cc_detail'} = [ map { $self->_user_to_hash($_, $params, 'cc') } @{ $bug->cc_users } ]; } if (filter_wants $params, 'creator') { - $item{'creator'} = $self->type('string', $bug->reporter->login); + $item{'creator'} = $self->type('email', $bug->reporter->login); + $item{'creator_detail'} = $self->_user_to_hash($bug->reporter, $params, 'creator'); } if (filter_wants $params, 'depends_on') { my @depends_on = map { $self->type('int', $_) } @{ $bug->dependson }; @@ -898,23 +1194,32 @@ sub _bug_to_hash { } if (filter_wants $params, 'qa_contact') { my $qa_login = $bug->qa_contact ? $bug->qa_contact->login : ''; - $item{'qa_contact'} = $self->type('string', $qa_login); + $item{'qa_contact'} = $self->type('email', $qa_login); + if ($bug->qa_contact) { + $item{'qa_contact_detail'} = $self->_user_to_hash($bug->qa_contact, $params, 'qa_contact'); + } } if (filter_wants $params, 'see_also') { my @see_also = map { $self->type('string', $_->name) } @{ $bug->see_also }; $item{'see_also'} = \@see_also; } + if (filter_wants $params, 'flags') { + $item{'flags'} = [ map { $self->_flag_to_hash($_) } @{$bug->flags} ]; + } # And now custom fields - my @custom_fields = Bugzilla->active_custom_fields; + my @custom_fields = Bugzilla->active_custom_fields({ + product => $bug->product_obj, component => $bug->component_obj, bug_id => $bug->id }); foreach my $field (@custom_fields) { my $name = $field->name; next if !filter_wants $params, $name; if ($field->type == FIELD_TYPE_BUG_ID) { $item{$name} = $self->type('int', $bug->$name); } - elsif ($field->type == FIELD_TYPE_DATETIME) { + elsif ($field->type == FIELD_TYPE_DATETIME + || $field->type == FIELD_TYPE_DATE) + { $item{$name} = $self->type('dateTime', $bug->$name); } elsif ($field->type == FIELD_TYPE_MULTI_SELECT) { @@ -933,11 +1238,7 @@ sub _bug_to_hash { # No need to format $bug->deadline specially, because Bugzilla::Bug # already does it for us. $item{'deadline'} = $self->type('string', $bug->deadline); - } - - if (Bugzilla->user->id) { - my $token = issue_hash_token([$bug->id, $bug->delta_ts]); - $item{'update_token'} = $self->type('string', $token); + $item{'actual_time'} = $self->type('double', $bug->actual_time); } # The "accessible" bits go here because they have long names and it @@ -950,12 +1251,20 @@ sub _bug_to_hash { return filter $params, \%item; } +sub _user_to_hash { + my ($self, $user, $filters, $prefix) = @_; + my $item = filter $filters, { + id => $self->type('int', $user->id), + real_name => $self->type('string', $user->name), + name => $self->type('email', $user->login), + email => $self->type('email', $user->email), + }, $prefix; + return $item; +} + sub _attachment_to_hash { my ($self, $attach, $filters) = @_; - # Skipping attachment flags for now. - delete $attach->{flags}; - my $item = filter $filters, { creation_time => $self->type('dateTime', $attach->attached), last_change_time => $self->type('dateTime', $attach->modification_time), @@ -974,7 +1283,7 @@ sub _attachment_to_hash { # the filter wants them. foreach my $field (qw(creator attacher)) { if (filter_wants $filters, $field) { - $item->{$field} = $self->type('string', $attach->attacher->login); + $item->{$field} = $self->type('email', $attach->attacher->login); } } @@ -982,9 +1291,50 @@ sub _attachment_to_hash { $item->{'data'} = $self->type('base64', $attach->data); } + if (filter_wants $filters, 'size') { + $item->{'size'} = $self->type('int', $attach->datasize); + } + + if (filter_wants $filters, 'flags') { + $item->{'flags'} = [ map { $self->_flag_to_hash($_) } @{$attach->flags} ]; + } + return $item; } +sub _flag_to_hash { + my ($self, $flag) = @_; + + my $item = { + id => $self->type('int', $flag->id), + name => $self->type('string', $flag->name), + type_id => $self->type('int', $flag->type_id), + creation_date => $self->type('dateTime', $flag->creation_date), + modification_date => $self->type('dateTime', $flag->modification_date), + status => $self->type('string', $flag->status) + }; + + foreach my $field (qw(setter requestee)) { + my $field_id = $field . "_id"; + $item->{$field} = $self->type('email', $flag->$field->login) + if $flag->$field_id; + } + + return $item; +} + +sub _add_update_tokens { + my ($self, $params, $bugs, $hashes) = @_; + + return if !Bugzilla->user->id; + return if !filter_wants($params, 'update_token'); + + for(my $i = 0; $i < @$bugs; $i++) { + my $token = issue_hash_token([$bugs->[$i]->id, $bugs->[$i]->delta_ts]); + $hashes->[$i]->{'update_token'} = $self->type('string', $token); + } +} + 1; __END__ @@ -1004,6 +1354,10 @@ or get information about bugs that have already been filed. See L<Bugzilla::WebService> for a description of how parameters are passed, and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. +Although the data input and output is the same for JSONRPC, XMLRPC and REST, +the directions for how to access the data via REST is noted in each method +where applicable. + =head1 Utility Functions =head2 fields @@ -1017,11 +1371,26 @@ B<UNSTABLE> Get information about valid bug fields, including the lists of legal values for each field. +=item B<REST> + +You have several options for retreiving information about fields. The first +part is the request method and the rest is the related path needed. + +To get information about all fields: + +GET /field/bug + +To get information related to a single field: + +GET /field/bug/<id_or_name> + +The returned data format is the same as below. + =item B<Params> You can pass either field ids or field names. -B<Note>: If neither C<ids> nor C<names> is specified, then all +B<Note>: If neither C<ids> nor C<names> is specified, then all non-obsolete fields will be returned. In addition to the parameters below, this method also accepts the @@ -1120,7 +1489,7 @@ values of the field are shown in the user interface. Can be null. This is an array of hashes, representing the legal values for select-type (drop-down and multiple-selection) fields. This is also -populated for the C<component>, C<version>, and C<target_milestone> +populated for the C<component>, C<version>, C<target_milestone>, and C<keywords> fields, but not for the C<product> field (you must use L<Product.get_accessible_products|Bugzilla::WebService::Product/get_accessible_products> for that. @@ -1153,6 +1522,11 @@ if the C<value_field> is set to one of the values listed in this array. Note that for per-product fields, C<value_field> is set to C<'product'> and C<visibility_values> will reflect which product(s) this value appears in. +=item C<description> + +C<string> The description of the value. This item is only included for the +C<keywords> field. + =item C<is_open> C<boolean> For C<bug_status> values, determines whether this status @@ -1206,6 +1580,8 @@ You specified an invalid field name or id. =back +=item REST API call added in Bugzilla B<5.0> + =back @@ -1219,6 +1595,18 @@ B<DEPRECATED> - Use L</fields> instead. Tells you what values are allowed for a particular field. +=item B<REST> + +To get information on the values for a field based on field name: + +GET /field/bug/<field_name>/values + +To get information based on field name and a specific product: + +GET /field/bug/<field_name>/<product_id>/values + +The returned data format is the same as below. + =item B<Params> =over @@ -1251,6 +1639,14 @@ You specified a field that doesn't exist or isn't a drop-down field. =back +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head1 Bug Information @@ -1269,6 +1665,18 @@ and/or attachment ids. B<Note>: Private attachments will only be returned if you are in the insidergroup or if you are the submitter of the attachment. +=item B<REST> + +To get all current attachments for a bug: + +GET /bug/<bug_id>/attachment + +To get a specific attachment based on attachment ID: + +GET /bug/attachment/<attachment_id> + +The returned data format is the same as below. + =item B<Params> B<Note>: At least one of C<ids> or C<attachment_ids> is required. @@ -1329,6 +1737,10 @@ diagram above) are: C<base64> The raw data of the attachment, encoded as Base64. +=item C<size> + +C<int> The length (in bytes) of the attachment. + =item C<creation_time> C<dateTime> The time the attachment was created. @@ -1382,6 +1794,48 @@ Also returned as C<attacher>, for backwards-compatibility with older Bugzillas. (However, this backwards-compatibility will go away in Bugzilla 5.0.) +=item C<flags> + +An array of hashes containing the information about flags currently set +for each attachment. Each flag hash contains the following items: + +=over + +=item C<id> + +C<int> The id of the flag. + +=item C<name> + +C<string> The name of the flag. + +=item C<type_id> + +C<int> The type id of the flag. + +=item C<creation_date> + +C<dateTime> The timestamp when this flag was originally created. + +=item C<modification_date> + +C<dateTime> The timestamp when the flag was last modified. + +=item C<status> + +C<string> The current status of the flag. + +=item C<setter> + +C<string> The login name of the user who created or last modified the flag. + +=item C<requestee> + +C<string> The login name of the user this flag has been requested to be granted or denied. +Note, this field is only returned if a requestee is set. + +=back + =back =item B<Errors> @@ -1418,6 +1872,10 @@ C<summary>. =back +=item The C<flags> array was added in Bugzilla B<4.4>. + +=item REST API call added in Bugzilla B<5.0>. + =back @@ -1432,6 +1890,18 @@ B<STABLE> This allows you to get data about comments, given a list of bugs and/or comment ids. +=item B<REST> + +To get all comments for a particular bug using the bug ID or alias: + +GET /bug/<id_or_alias>/comment + +To get a specific comment based on the comment ID: + +GET /bug/comment/<comment_id> + +The returned data format is the same as below. + =item B<Params> B<Note>: At least one of C<ids> or C<comment_ids> is required. @@ -1522,6 +1992,13 @@ Bugzillas. (However, this backwards-compatibility will go away in Bugzilla C<dateTime> The time (in Bugzilla's timezone) that the comment was added. +=item creation_time + +C<dateTime> This is exactly same as the C<time> key. Use this field instead of +C<time> for consistency with other methods including L</get> and L</attachments>. +For compatibility, C<time> is still usable. However, please note that C<time> +may be deprecated and removed in a future release. + =item is_private C<boolean> True if this comment is private (only visible to a certain @@ -1563,6 +2040,10 @@ C<creator>. =back +=item C<creation_time> was added in Bugzilla B<4.4>. + +=item REST API call added in Bugzilla B<5.0>. + =back @@ -1578,6 +2059,14 @@ Gets information about particular bugs in the database. Note: Can also be called as "get_bugs" for compatibilty with Bugzilla 3.0 API. +=item B<REST> + +To get information about a particular bug using its ID or alias: + +GET /bug/<id_or_alias> + +The returned data format is the same as below. + =item B<Params> In addition to the parameters below, this method also accepts the @@ -1622,6 +2111,13 @@ the valid ids. Each hash contains the following items: =over +=item C<actual_time> + +C<double> The total number of hours that this bug has taken (so far). + +If you are not in the time-tracking group, this field will not be included +in the return value. + =item C<alias> C<string> The unique alias of this bug. @@ -1630,6 +2126,11 @@ C<string> The unique alias of this bug. C<string> The login name of the user to whom the bug is assigned. +=item C<assigned_to_detail> + +C<hash> A hash containing detailed user information for the assigned_to. To see the +keys included in the user detail hash, see below. + =item C<blocks> C<array> of C<int>s. The ids of bugs that are "blocked" by this bug. @@ -1639,6 +2140,11 @@ C<array> of C<int>s. The ids of bugs that are "blocked" by this bug. C<array> of C<string>s. The login names of users on the CC list of this bug. +=item C<cc_detail> + +C<array> of hashes containing detailed user information for each of the cc list +members. To see the keys included in the user detail hash, see below. + =item C<classification> C<string> The name of the current classification the bug is in. @@ -1655,6 +2161,11 @@ C<dateTime> When the bug was created. C<string> The login name of the person who filed this bug (the reporter). +=item C<creator_detail> + +C<hash> A hash containing detailed user information for the creator. To see the +keys included in the user detail hash, see below. + =item C<deadline> C<string> The day that this bug is due to be completed, in the format @@ -1680,6 +2191,48 @@ take. If you are not in the time-tracking group, this field will not be included in the return value. +=item C<flags> + +An array of hashes containing the information about flags currently set +for the bug. Each flag hash contains the following items: + +=over + +=item C<id> + +C<int> The id of the flag. + +=item C<name> + +C<string> The name of the flag. + +=item C<type_id> + +C<int> The type id of the flag. + +=item C<creation_date> + +C<dateTime> The timestamp when this flag was originally created. + +=item C<modification_date> + +C<dateTime> The timestamp when the flag was last modified. + +=item C<status> + +C<string> The current status of the flag. + +=item C<setter> + +C<string> The login name of the user who created or last modified the flag. + +=item C<requestee> + +C<string> The login name of the user this flag has been requested to be granted or denied. +Note, this field is only returned if a requestee is set. + +=back + =item C<groups> C<array> of C<string>s. The names of all the groups that this bug is in. @@ -1737,6 +2290,11 @@ C<string> The name of the product this bug is in. C<string> The login name of the current QA Contact on the bug. +=item C<qa_contact_detail> + +C<hash> A hash containing detailed user information for the qa_contact. To see the +keys included in the user detail hash, see below. + =item C<remaining_time> C<double> The number of hours of work remaining until work on this bug @@ -1812,6 +2370,30 @@ field types have different return values: =back +=item I<user detail hashes> + +Each user detail hash contains the following items: + +=over + +=item C<id> + +C<int> The user id for this user. + +=item C<real_name> + +C<string> The 'real' name for this user, if any. + +=item C<name> + +C<string> The user's Bugzilla login. + +=item C<email> + +C<string> The user's email address. Currently this is the same value as the name. + +=back + =back =item C<faults> B<EXPERIMENTAL> @@ -1869,6 +2451,8 @@ You do not have access to the bug_id you specified. =item The following properties were added to this method's return values in Bugzilla B<3.4>: +=item REST API call added in Bugzilla B<5.0> + =over =item For C<bugs> @@ -1907,8 +2491,12 @@ C<op_sys>, C<platform>, C<qa_contact>, C<remaining_time>, C<see_also>, C<target_milestone>, C<update_token>, C<url>, C<version>, C<whiteboard>, and all custom fields. -=back +=item The C<flags> array was added in Bugzilla B<4.4>. +=item The C<actual_time> item was added to the C<bugs> return value +in Bugzilla B<4.4>. + +=back =back @@ -1922,6 +2510,14 @@ B<EXPERIMENTAL> Gets the history of changes for particular bugs in the database. +=item B<REST> + +To get the history for a specific bug ID: + +GET /bug/<bug_id>/history + +The returned data format will be the same as below. + =item B<Params> =over @@ -1933,7 +2529,12 @@ An array of numbers and strings. If an element in the array is entirely numeric, it represents a bug_id from the Bugzilla database to fetch. If it contains any non-numeric characters, it is considered to be a bug alias instead, and the data bug -with that alias will be loaded. +with that alias will be loaded. + +item C<new_since> + +C<dateTime> If specified, the method will only return changes I<newer> +than this time. Note that it's possible for aliases to be disabled in Bugzilla, in which case you will be told that you have specified an invalid bug_id if you @@ -2002,6 +2603,8 @@ present in this hash. =back +=item REST API call added Bugzilla B<5.0>. + =back =item B<Errors> @@ -2014,6 +2617,10 @@ The same as L</get>. =item Added in Bugzilla B<3.4>. +=item Field names changed to be more consistent with other methods in Bugzilla B<4.4>. + +=item As of Bugzilla B<4.4>, field names now match names used by L<Bug.update|/"update"> for consistency. + =back =back @@ -2082,6 +2689,14 @@ B<UNSTABLE> Allows you to search for bugs based on particular criteria. +=item <REST> + +To search for bugs: + +GET /bug + +The URL parameters and the returned data format are the same as below. + =item B<Params> Unless otherwise specified in the description of a parameter, bugs are @@ -2102,10 +2717,19 @@ the "Foo" or "Bar" products, you'd pass: product => ['Foo', 'Bar'] Some Bugzillas may treat your arguments case-sensitively, depending -on what database system they are using. Most commonly, though, Bugzilla is -not case-sensitive with the arguments passed (because MySQL is the +on what database system they are using. Most commonly, though, Bugzilla is +not case-sensitive with the arguments passed (because MySQL is the most-common database to use with Bugzilla, and MySQL is not case sensitive). +In addition to the fields listed below, you may also use criteria that +is similar to what is used in the Advanced Search screen of the Bugzilla +UI. This includes fields specified by C<Search by Change History> and +C<Custom Search>. The easiest way to determine what the field names are and what +format Bugzilla expects, is to first construct your query using the +Advanced Search UI, execute it and use the query parameters in they URL +as your key/value pairs for the WebService call. With REST, you can +just reuse the query parameter portion in the REST call itself. + =over =item C<alias> @@ -2230,6 +2854,15 @@ C<string> Search the "Status Whiteboard" field on bugs for a substring. Works the same as the C<summary> field described above, but searches the Status Whiteboard field. +=item C<count_only> + +C<boolean> If count_only set to true, only a single hash key called C<bug_count> +will be returned which is the number of bugs that matched the search. + +=item C<quicksearch> + +C<string> Search for bugs using quicksearch syntax. + =back =item B<Returns> @@ -2269,6 +2902,13 @@ in Bugzilla B<4.0>. C<limit> is set equal to zero. Otherwise maximum results returned are limited by system configuration. +=item REST API call added in Bugzilla B<5.0>. + +=item Updated to allow for full search capability similar to the Bugzilla UI +in Bugzilla B<5.0>. + +=item Updated to allow quicksearch capability in Bugzilla B<5.0>. + =back =back @@ -2295,10 +2935,19 @@ The WebService interface may allow you to set things other than those listed here, but realize that anything undocumented is B<UNSTABLE> and will very likely change in the future. +=item B<REST> + +To create a new bug in Bugzilla: + +POST /bug + +The params to include in the POST body as well as the returned data format, +are the same as below. + =item B<Params> Some params must be set, or an error will be thrown. These params are -marked B<Required>. +marked B<Required>. Some parameters can have defaults set in Bugzilla, by the administrator. If these parameters have defaults set, you can omit them. These parameters @@ -2453,6 +3102,8 @@ loop errors had a generic code of C<32000>. =back +=item REST API call added in Bugzilla B<5.0>. + =back @@ -2466,6 +3117,16 @@ B<UNSTABLE> This allows you to add an attachment to a bug in Bugzilla. +=item B<REST> + +To create attachment on a current bug: + +POST /bug/<bug_id>/attachment + +The params to include in the POST body, as well as the returned +data format are the same as below. The C<ids> param will be +overridden as it it pulled from the URL path. + =item B<Params> =over @@ -2564,6 +3225,158 @@ You set the "data" field to an empty string. =back +=item REST API call added in Bugzilla B<5.0>. + +=back + + +=head2 update_attachment + +B<UNSTABLE> + +=over + +=item B<Description> + +This allows you to update attachment metadata in Bugzilla. + +=item B<REST> + +To update attachment metadata on a current attachment: + +PUT /bug/attachment/<attach_id> + +The params to include in the POST body, as well as the returned +data format are the same as below. The C<ids> param will be +overridden as it it pulled from the URL path. + +=item B<Params> + +=over + +=item C<ids> + +B<Required> C<array> An array of integers -- the ids of the attachments you +want to update. + +=item C<file_name> + +C<string> The "file name" that will be displayed +in the UI for this attachment. + +=item C<summary> + +C<string> A short string describing the +attachment. + +=item C<content_type> + +C<string> The MIME type of the attachment, like +C<text/plain> or C<image/png>. + +=item C<is_patch> + +C<boolean> True if Bugzilla should treat this attachment as a patch. +If you specify this, you do not need to specify a C<content_type>. +The C<content_type> of the attachment will be forced to C<text/plain>. + +=item C<is_private> + +C<boolean> True if the attachment should be private (restricted +to the "insidergroup"), False if the attachment should be public. + +=item C<is_obsolete> + +C<boolean> True if the attachment is obsolete, False otherwise. + +=back + +=item B<Returns> + +A C<hash> with a single field, "attachment". This points to an array of hashes +with the following fields: + +=over + +=item C<id> + +C<int> The id of the attachment that was updated. + +=item C<last_change_time> + +C<dateTime> The exact time that this update was done at, for this attachment. +If no update was done (that is, no fields had their values changed and +no comment was added) then this will instead be the last time the attachment +was updated. + +=item C<changes> + +C<hash> The changes that were actually done on this bug. The keys are +the names of the fields that were changed, and the values are a hash +with two keys: + +=over + +=item C<added> (C<string>) The values that were added to this field. +possibly a comma-and-space-separated list if multiple values were added. + +=item C<removed> (C<string>) The values that were removed from this +field. + +=back + +=back + +Here's an example of what a return value might look like: + + { + attachments => [ + { + id => 123, + last_change_time => '2010-01-01T12:34:56', + changes => { + summary => { + removed => 'Sample ptach', + added => 'Sample patch' + }, + is_obsolete => { + removed => '0', + added => '1', + } + }, + } + ] + } + +=item B<Errors> + +This method can throw all the same errors as L</get>, plus: + +=over + +=item 601 (Invalid MIME Type) + +You specified a C<content_type> argument that was blank, not a valid +MIME type, or not a MIME type that Bugzilla accepts for attachments. + +=item 603 (File Name Not Specified) + +You did not specify a valid for the C<file_name> argument. + +=item 604 (Summary Required) + +You did not specify a value for the C<summary> argument. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + =back @@ -2577,6 +3390,15 @@ B<STABLE> This allows you to add a comment to a bug in Bugzilla. +=item B<REST> + +To create a comment on a current bug: + +POST /bug/<bug_id>/comment + +The params to include in the POST body as well as the returned data format, +are the same as below. + =item B<Params> =over @@ -2653,6 +3475,10 @@ purposes if you wish. =item Before Bugzilla B<3.6>, error 54 and error 114 had a generic error code of 32000. +=item REST API call added in Bugzilla B<5.0>. + +=item In Bugzilla B<5.0>, the following items were added to the bugs return value: C<assigned_to_detail>, C<creator_detail>, C<qa_contact_detail>. + =back =back @@ -2669,6 +3495,16 @@ B<UNSTABLE> Allows you to update the fields of a bug. Automatically sends emails out about the changes. +=item B<REST> + +To update the fields of a current bug: + +PUT /bug/<bug_id> + +The params to include in the PUT body as well as the returned data format, +are the same as below. The C<ids> param will be overridden as it is +pulled from the URL path. + =item B<Params> =over @@ -3114,6 +3950,10 @@ rules don't allow that change. =item Added in Bugzilla B<4.0>. +=item REST API call added Bugzilla B<5.0>. + +=item Added C<new_since> parameter if Bugzilla B<5.0>. + =back =back diff --git a/Bugzilla/WebService/Bugzilla.pm b/Bugzilla/WebService/Bugzilla.pm index efc822311..1fc15c3c3 100644 --- a/Bugzilla/WebService/Bugzilla.pm +++ b/Bugzilla/WebService/Bugzilla.pm @@ -98,6 +98,10 @@ This provides functions that tell you about Bugzilla in general. See L<Bugzilla::WebService> for a description of how parameters are passed, and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. +Although the data input and output is the same for JSONRPC, XMLRPC and REST, +the directions for how to access the data via REST is noted in each method +where applicable. + =head2 version B<STABLE> @@ -108,6 +112,12 @@ B<STABLE> Returns the current version of Bugzilla. +=item B<REST> + +GET /version + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -117,6 +127,14 @@ string. =item B<Errors> (none) +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head2 extensions @@ -130,6 +148,12 @@ B<EXPERIMENTAL> Gets information about the extensions that are currently installed and enabled in this Bugzilla. +=item B<REST> + +GET /extensions + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -160,6 +184,8 @@ The return value looks something like this: that the extensions define themselves. Before 3.6, the names of the extensions depended on the directory they were in on the Bugzilla server. +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -175,6 +201,12 @@ Use L</time> instead. Returns the timezone that Bugzilla expects dates and times in. +=item B<REST> + +GET /timezone + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -189,6 +221,8 @@ string in (+/-)XXXX (RFC 2822) format. =item As of Bugzilla B<3.6>, the timezone returned is always C<+0000> (the UTC timezone). +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -205,6 +239,12 @@ B<STABLE> Gets information about what time the Bugzilla server thinks it is, and what timezone it's running in. +=item B<REST> + +GET /time + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -215,7 +255,7 @@ A struct with the following items: =item C<db_time> -C<dateTime> The current time in UTC, according to the Bugzilla +C<dateTime> The current time in UTC, according to the Bugzilla I<database server>. Note that Bugzilla assumes that the database and the webserver are running @@ -225,7 +265,7 @@ rely on for doing searches and other input to the WebService. =item C<web_time> -C<dateTime> This is the current time in UTC, according to Bugzilla's +C<dateTime> This is the current time in UTC, according to Bugzilla's I<web server>. This might be different by a second from C<db_time> since this comes from @@ -241,7 +281,7 @@ versions of Bugzilla before 3.6.) =item C<tz_name> C<string> The literal string C<UTC>. (Exists only for backwards-compatibility -with versions of Bugzilla before 3.6.) +with versions of Bugzilla before 3.6.) =item C<tz_short_name> @@ -265,6 +305,8 @@ with versions of Bugzilla before 3.6.) were in the UTC timezone, instead of returning information in the server's local timezone. +=item REST API call added in Bugzilla B<5.0>. + =back =back diff --git a/Bugzilla/WebService/Classification.pm b/Bugzilla/WebService/Classification.pm new file mode 100644 index 000000000..22358c784 --- /dev/null +++ b/Bugzilla/WebService/Classification.pm @@ -0,0 +1,210 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Classification; + +use 5.10.1; +use strict; + +use parent qw (Bugzilla::WebService); + +use Bugzilla::Classification; +use Bugzilla::Error; +use Bugzilla::WebService::Util qw(filter validate params_to_objects); + +use constant READ_ONLY => qw( + get +); + +sub get { + my ($self, $params) = validate(@_, 'names', 'ids'); + + defined $params->{names} || defined $params->{ids} + || ThrowCodeError('params_required', { function => 'Classification.get', + params => ['names', 'ids'] }); + + my $user = Bugzilla->user; + + Bugzilla->params->{'useclassification'} + || $user->in_group('editclassifications') + || ThrowUserError('auth_classification_not_enabled'); + + Bugzilla->switch_to_shadow_db; + + my @classification_objs = @{ params_to_objects($params, 'Bugzilla::Classification') }; + unless ($user->in_group('editclassifications')) { + my %selectable_class = map { $_->id => 1 } @{$user->get_selectable_classifications}; + @classification_objs = grep { $selectable_class{$_->id} } @classification_objs; + } + + my @classifications = map { filter($params, $self->_classification_to_hash($_)) } @classification_objs; + + return { classifications => \@classifications }; +} + +sub _classification_to_hash { + my ($self, $classification) = @_; + + my $user = Bugzilla->user; + return unless (Bugzilla->params->{'useclassification'} || $user->in_group('editclassifications')); + + my $products = $user->in_group('editclassifications') ? + $classification->products : $user->get_selectable_products($classification->id); + my %hash = ( + id => $self->type('int', $classification->id), + name => $self->type('string', $classification->name), + description => $self->type('string', $classification->description), + sort_key => $self->type('int', $classification->sortkey), + products => [ map { $self->_product_to_hash($_) } @$products ], + ); + + return \%hash; +} + +sub _product_to_hash { + my ($self, $product) = @_; + my %hash = ( + id => $self->type('int', $product->id), + name => $self->type('string', $product->name), + description => $self->type('string', $product->description), + ); + + return \%hash; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Classification - The Classification API + +=head1 DESCRIPTION + +This part of the Bugzilla API allows you to deal with the available Classifications. +You will be able to get information about them as well as manipulate them. + +=head1 METHODS + +See L<Bugzilla::WebService> for a description of how parameters are passed, +and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. + +Although the data input and output is the same for JSONRPC, XMLRPC and REST, +the directions for how to access the data via REST is noted in each method +where applicable. + +=head1 Classification Retrieval + +=head2 get + +B<EXPERIMENTAL> + +=over + +=item B<Description> + +Returns a hash containing information about a set of classifications. + +=item B<REST> + +To return information on a single classification: + +GET /classification/<classification_id_or_name> + +The returned data format will be the same as below. + +=item B<Params> + +In addition to the parameters below, this method also accepts the +standard L<include_fields|Bugzilla::WebService/include_fields> and +L<exclude_fields|Bugzilla::WebService/exclude_fields> arguments. + +You could get classifications info by supplying their names and/or ids. +So, this method accepts the following parameters: + +=over + +=item C<ids> + +An array of classification ids. + +=item C<names> + +An array of classification names. + +=back + +=item B<Returns> + +A hash with the key C<classifications> and an array of hashes as the corresponding value. +Each element of the array represents a classification that the user is authorized to see +and has the following keys: + +=over + +=item C<id> + +C<int> The id of the classification. + +=item C<name> + +C<string> The name of the classification. + +=item C<description> + +C<string> The description of the classificaion. + +=item C<sort_key> + +C<int> The value which determines the order the classification is sorted. + +=item C<products> + +An array of hashes. The array contains the products the user is authorized to +access within the classification. Each hash has the following keys: + +=over + +=item C<name> + +C<string> The name of the product. + +=item C<id> + +C<int> The id of the product. + +=item C<description> + +C<string> The description of the product. + +=back + +=back + +=item B<Errors> + +=over + +=item 900 (Classification not enabled) + +Classification is not enabled on this installation. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<4.4>. + +=item REST API call added in Bugzilla B<5.0>. + +=back + +=back + diff --git a/Bugzilla/WebService/Constants.pm b/Bugzilla/WebService/Constants.pm index 3207356fa..87d890176 100644 --- a/Bugzilla/WebService/Constants.pm +++ b/Bugzilla/WebService/Constants.pm @@ -22,9 +22,22 @@ use base qw(Exporter); our @EXPORT = qw( WS_ERROR_CODE + + STATUS_OK + STATUS_CREATED + STATUS_ACCEPTED + STATUS_NO_CONTENT + STATUS_MULTIPLE_CHOICES + STATUS_BAD_REQUEST + STATUS_NOT_FOUND + STATUS_GONE + REST_STATUS_CODE_MAP + ERROR_UNKNOWN_FATAL ERROR_UNKNOWN_TRANSIENT + XMLRPC_CONTENT_TYPE_WHITELIST + REST_CONTENT_TYPE_WHITELIST WS_DISPATCH ); @@ -93,7 +106,12 @@ use constant WS_ERROR_CODE => { comment_is_private => 110, comment_id_invalid => 111, comment_too_long => 114, - comment_invalid_isprivate => 117, + comment_invalid_isprivate => 117, + # Comment tagging + comment_tag_disabled => 125, + comment_tag_invalid => 126, + comment_tag_too_long => 127, + comment_tag_too_short => 128, # See Also errors bug_url_invalid => 112, bug_url_too_long => 112, @@ -177,8 +195,47 @@ use constant WS_ERROR_CODE => { unknown_method => -32601, json_rpc_post_only => 32610, json_rpc_invalid_callback => 32611, - xmlrpc_illegal_content_type => 32612, - json_rpc_illegal_content_type => 32613, + xmlrpc_illegal_content_type => 32612, + json_rpc_illegal_content_type => 32613, + rest_invalid_resource => 32614, +}; + +# RESTful webservices use the http status code +# to describe whether a call was successful or +# to describe the type of error that occurred. +use constant STATUS_OK => 200; +use constant STATUS_CREATED => 201; +use constant STATUS_ACCEPTED => 202; +use constant STATUS_NO_CONTENT => 204; +use constant STATUS_MULTIPLE_CHOICES => 300; +use constant STATUS_BAD_REQUEST => 400; +use constant STATUS_NOT_AUTHORIZED => 401; +use constant STATUS_NOT_FOUND => 404; +use constant STATUS_GONE => 410; + +# The integer value is the error code above returned by +# the related webvservice call. We choose the appropriate +# http status code based on the error code or use the +# default STATUS_BAD_REQUEST. +use constant REST_STATUS_CODE_MAP => { + 51 => STATUS_NOT_FOUND, + 101 => STATUS_NOT_FOUND, + 102 => STATUS_NOT_AUTHORIZED, + 106 => STATUS_NOT_AUTHORIZED, + 109 => STATUS_NOT_AUTHORIZED, + 110 => STATUS_NOT_AUTHORIZED, + 113 => STATUS_NOT_AUTHORIZED, + 115 => STATUS_NOT_AUTHORIZED, + 120 => STATUS_NOT_AUTHORIZED, + 300 => STATUS_NOT_AUTHORIZED, + 301 => STATUS_NOT_AUTHORIZED, + 302 => STATUS_NOT_AUTHORIZED, + 303 => STATUS_NOT_AUTHORIZED, + 304 => STATUS_NOT_AUTHORIZED, + 410 => STATUS_NOT_AUTHORIZED, + 504 => STATUS_NOT_AUTHORIZED, + 505 => STATUS_NOT_AUTHORIZED, + _default => STATUS_BAD_REQUEST }; # These are the fallback defaults for errors not in ERROR_CODE. @@ -192,6 +249,13 @@ use constant XMLRPC_CONTENT_TYPE_WHITELIST => qw( application/xml ); +use constant REST_CONTENT_TYPE_WHITELIST => qw( + text/html + application/javascript + application/json + text/javascript +); + sub WS_DISPATCH { # We "require" here instead of "use" above to avoid a dependency loop. require Bugzilla::Hook; @@ -199,11 +263,12 @@ sub WS_DISPATCH { Bugzilla::Hook::process('webservice', { dispatch => \%hook_dispatch }); my $dispatch = { - 'Bugzilla' => 'Bugzilla::WebService::Bugzilla', - 'Bug' => 'Bugzilla::WebService::Bug', - 'User' => 'Bugzilla::WebService::User', - 'Product' => 'Bugzilla::WebService::Product', - 'Group' => 'Bugzilla::WebService::Group', + 'Bugzilla' => 'Bugzilla::WebService::Bugzilla', + 'Bug' => 'Bugzilla::WebService::Bug', + 'Classification' => 'Bugzilla::WebService::Classification', + 'User' => 'Bugzilla::WebService::User', + 'Product' => 'Bugzilla::WebService::Product', + 'Group' => 'Bugzilla::WebService::Group', %hook_dispatch }; return $dispatch; diff --git a/Bugzilla/WebService/Group.pm b/Bugzilla/WebService/Group.pm index 65feb7a1a..b571a1062 100644 --- a/Bugzilla/WebService/Group.pm +++ b/Bugzilla/WebService/Group.pm @@ -61,6 +61,10 @@ get information about them. See L<Bugzilla::WebService> for a description of how parameters are passed, and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. +Although the data input and output is the same for JSONRPC, XMLRPC and REST, +the directions for how to access the data via REST is noted in each method +where applicable. + =head1 Group Creation =head2 create @@ -73,9 +77,16 @@ B<UNSTABLE> This allows you to create a new group in Bugzilla. -=item B<Params> +=item B<REST> + +POST /group + +The params to include in the POST body as well as the returned data format, +are the same as below. -Some params must be set, or an error will be thrown. These params are +=item B<Params> + +Some params must be set, or an error will be thrown. These params are marked B<Required>. =over @@ -96,7 +107,7 @@ name of the group. C<string> A regular expression. Any user whose Bugzilla username matches this regular expression will automatically be granted membership in this group. -=item C<is_active> +=item C<is_active> C<boolean> C<True> if new group can be used for bugs, C<False> if this is a group that will only contain users and no bugs will be restricted @@ -110,7 +121,7 @@ if they are in this group. =back -=item B<Returns> +=item B<Returns> A hash with one element, C<id>. This is the id of the newly-created group. @@ -136,6 +147,14 @@ You specified an invalid regular expression in the C<user_regexp> field. =back -=back +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + +=back =cut diff --git a/Bugzilla/WebService/Product.pm b/Bugzilla/WebService/Product.pm index 3cd0d0a6c..ca22e6418 100644 --- a/Bugzilla/WebService/Product.pm +++ b/Bugzilla/WebService/Product.pm @@ -47,57 +47,95 @@ BEGIN { *get_products = \&get } # Get the ids of the products the user can search sub get_selectable_products { - return {ids => [map {$_->id} @{Bugzilla->user->get_selectable_products}]}; + Bugzilla->switch_to_shadow_db(); + return {ids => [map {$_->id} @{Bugzilla->user->get_selectable_products}]}; } # Get the ids of the products the user can enter bugs against sub get_enterable_products { - return {ids => [map {$_->id} @{Bugzilla->user->get_enterable_products}]}; + Bugzilla->switch_to_shadow_db(); + return {ids => [map {$_->id} @{Bugzilla->user->get_enterable_products}]}; } # Get the union of the products the user can search and enter bugs against. sub get_accessible_products { - return {ids => [map {$_->id} @{Bugzilla->user->get_accessible_products}]}; + Bugzilla->switch_to_shadow_db(); + return {ids => [map {$_->id} @{Bugzilla->user->get_accessible_products}]}; } # Get a list of actual products, based on list of ids or names sub get { - my ($self, $params) = validate(@_, 'ids', 'names'); - - # Only products that are in the users accessible products, - # can be allowed to be returned - my $accessible_products = Bugzilla->user->get_accessible_products; + my ($self, $params) = validate(@_, 'ids', 'names', 'type'); + my $user = Bugzilla->user; + + defined $params->{ids} || defined $params->{names} || defined $params->{type} + || ThrowCodeError("params_required", { function => "Product.get", + params => ['ids', 'names', 'type'] }); + + Bugzilla->switch_to_shadow_db(); + + my $products = []; + if (defined $params->{type}) { + my %product_hash; + foreach my $type (@{ $params->{type} }) { + my $result = []; + if ($type eq 'accessible') { + $result = $user->get_accessible_products(); + } + elsif ($type eq 'enterable') { + $result = $user->get_enterable_products(); + } + elsif ($type eq 'selectable') { + $result = $user->get_selectable_products(); + } + else { + ThrowUserError('get_products_invalid_type', + { type => $type }); + } + map { $product_hash{$_->id} = $_ } @$result; + } + $products = [ values %product_hash ]; + } + else { + $products = $user->get_accessible_products; + } - my @requested_accessible; + my @requested_products; if (defined $params->{ids}) { # Create a hash with the ids the user wants my %ids = map { $_ => 1 } @{$params->{ids}}; - - # Return the intersection of this, by grepping the ids from + + # Return the intersection of this, by grepping the ids from # accessible products. - push(@requested_accessible, - grep { $ids{$_->id} } @$accessible_products); + push(@requested_products, + grep { $ids{$_->id} } @$products); } if (defined $params->{names}) { # Create a hash with the names the user wants my %names = map { lc($_) => 1 } @{$params->{names}}; - - # Return the intersection of this, by grepping the names from + + # Return the intersection of this, by grepping the names from # accessible products, union'ed with products found by ID to # avoid duplicates foreach my $product (grep { $names{lc $_->name} } - @$accessible_products) { + @$products) { next if grep { $_->id == $product->id } - @requested_accessible; - push @requested_accessible, $product; + @requested_products; + push @requested_products, $product; } } + # If we just requested a specific type of products without + # specifying ids or names, then return the entire list. + if (!defined $params->{ids} && !defined $params->{names}) { + @requested_products = @$products; + } + # Now create a result entry for each. my @products = map { $self->_product_to_hash($params, $_) } - @requested_accessible; + @requested_products; return { products => \@products }; } @@ -105,7 +143,7 @@ sub create { my ($self, $params) = @_; Bugzilla->login(LOGIN_REQUIRED); - Bugzilla->user->in_group('editcomponents') + Bugzilla->user->in_group('editcomponents') || ThrowUserError("auth_failure", { group => "editcomponents", action => "add", object => "products"}); @@ -143,46 +181,87 @@ sub _product_to_hash { }; if (filter_wants($params, 'components')) { $field_data->{components} = [map { - $self->_component_to_hash($_) + $self->_component_to_hash($_, $params) } @{$product->components}]; } if (filter_wants($params, 'versions')) { $field_data->{versions} = [map { - $self->_version_to_hash($_) + $self->_version_to_hash($_, $params) } @{$product->versions}]; } if (filter_wants($params, 'milestones')) { $field_data->{milestones} = [map { - $self->_milestone_to_hash($_) + $self->_milestone_to_hash($_, $params) } @{$product->milestones}]; } return filter($params, $field_data); } sub _component_to_hash { - my ($self, $component) = @_; - return { + my ($self, $component, $params) = @_; + my $field_data = { id => $self->type('int', $component->id), name => $self->type('string', $component->name), description => - $self->type('string' , $component->description), + $self->type('string', $component->description), default_assigned_to => - $self->type('string' , $component->default_assignee->login), + $self->type('email', $component->default_assignee->login), default_qa_contact => - $self->type('string' , $component->default_qa_contact->login), + $self->type('email', $component->default_qa_contact->login), sort_key => # sort_key is returned to match Bug.fields 0, is_active => $self->type('boolean', $component->is_active), }; + if (filter_wants($params, 'flag_types', 'components')) { + $field_data->{flag_types} = { + bug => + [map { + $self->_flag_type_to_hash($_) + } @{$component->flag_types->{'bug'}}], + attachment => + [map { + $self->_flag_type_to_hash($_) + } @{$component->flag_types->{'attachment'}}], + }; + } + return filter($params, $field_data, 'components'); } -sub _version_to_hash { - my ($self, $version) = @_; +sub _flag_type_to_hash { + my ($self, $flag_type, $params) = @_; return { id => + $self->type('int', $flag_type->id), + name => + $self->type('string', $flag_type->name), + description => + $self->type('string', $flag_type->description), + cc_list => + $self->type('string', $flag_type->cc_list), + sort_key => + $self->type('int', $flag_type->sortkey), + is_active => + $self->type('boolean', $flag_type->is_active), + is_requestable => + $self->type('boolean', $flag_type->is_requestable), + is_requesteeble => + $self->type('boolean', $flag_type->is_requesteeble), + is_multiplicable => + $self->type('boolean', $flag_type->is_multiplicable), + grant_group => + $self->type('int', $flag_type->grant_group_id), + request_group => + $self->type('int', $flag_type->request_group_id), + }; +} + +sub _version_to_hash { + my ($self, $version, $params) = @_; + my $field_data = { + id => $self->type('int', $version->id), name => $self->type('string', $version->name), @@ -191,11 +270,12 @@ sub _version_to_hash { is_active => $self->type('boolean', $version->is_active), }; + return filter($params, $field_data, 'versions'); } sub _milestone_to_hash { - my ($self, $milestone) = @_; - return { + my ($self, $milestone, $params) = @_; + my $field_data = { id => $self->type('int', $milestone->id), name => @@ -205,6 +285,7 @@ sub _milestone_to_hash { is_active => $self->type('boolean', $milestone->is_active), }; + return filter($params, $field_data, 'milestones'); } 1; @@ -225,6 +306,10 @@ get information about them. See L<Bugzilla::WebService> for a description of how parameters are passed, and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. +Although the data input and output is the same for JSONRPC, XMLRPC and REST, +the directions for how to access the data via REST is noted in each method +where applicable. + =head1 List Products =head2 get_selectable_products @@ -237,15 +322,29 @@ B<EXPERIMENTAL> Returns a list of the ids of the products the user can search on. +=item B<REST> + +GET /product_selectable + +the returned data format is same as below. + =item B<Params> (none) -=item B<Returns> +=item B<Returns> A hash containing one item, C<ids>, that contains an array of product ids. =item B<Errors> (none) +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head2 get_enterable_products @@ -259,6 +358,12 @@ B<EXPERIMENTAL> Returns a list of the ids of the products the user can enter bugs against. +=item B<REST> + +GET /product_enterable + +the returned data format is same as below. + =item B<Params> (none) =item B<Returns> @@ -268,6 +373,14 @@ ids. =item B<Errors> (none) +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head2 get_accessible_products @@ -281,6 +394,12 @@ B<UNSTABLE> Returns a list of the ids of the products the user can search or enter bugs against. +=item B<REST> + +GET /product_accessible + +the returned data format is same as below. + =item B<Params> (none) =item B<Returns> @@ -290,6 +409,14 @@ ids. =item B<Errors> (none) +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head2 get @@ -304,12 +431,32 @@ Returns a list of information about the products passed to it. Note: Can also be called as "get_products" for compatibilty with Bugzilla 3.0 API. +=item B<REST> + +To return information about a specific groups of products such as +C<accessible>, C<selectable>, or C<enterable>: + +GET /product?type=accessible + +To return information about a specific product by C<id> or C<name>: + +GET /product/<product_id_or_name> + +You can also return information about more than one specific product +by using the following in your query string: + +GET /product?ids=1&ids=2&ids=3 or GET /product?names=ProductOne&names=Product2 + +the returned data format is same as below. + =item B<Params> In addition to the parameters below, this method also accepts the standard L<include_fields|Bugzilla::WebService/include_fields> and L<exclude_fields|Bugzilla::WebService/exclude_fields> arguments. +This RPC call supports sub field restrictions. + =over =item C<ids> @@ -320,9 +467,15 @@ An array of product ids An array of product names +=item C<type> + +The group of products to return. Valid values are: C<accessible> (default), +C<selectable>, and C<enterable>. C<type> can be a single value or an array +of values if more than one group is needed with duplicates removed. + =back -=item B<Returns> +=item B<Returns> A hash containing one item, C<products>, that is an array of hashes. Each hash describes a product, and has the following items: @@ -400,6 +553,68 @@ and then secondly by their name. C<boolean> A boolean indicating if the component is active. Inactive components are not enabled for new bugs. +=item C<flag_types> + +A hash containing the two items C<bug> and C<attachment> that each contains an +array of hashes, where each hash describes a flagtype, and has the +following items: + +=over + +=item C<id> + +C<int> Returns the ID of the flagtype. + +=item C<name> + +C<string> Returns the name of the flagtype. + +=item C<description> + +C<string> Returns the description of the flagtype. + +=item C<cc_list> + +C<string> Returns the concatenated CC list for the flagtype, as a single string. + +=item C<sort_key> + +C<int> Returns the sortkey of the flagtype. + +=item C<is_active> + +C<boolean> Returns whether the flagtype is active or disabled. Flags being +in a disabled flagtype are not deleted. It only prevents you from +adding new flags to it. + +=item C<is_requestable> + +C<boolean> Returns whether you can request for the given flagtype +(i.e. whether the '?' flag is available or not). + +=item C<is_requesteeble> + +C<boolean> Returns whether you can ask someone specifically or not. + +=item C<is_multiplicable> + +C<boolean> Returns whether you can have more than one flag for the given +flagtype in a given bug/attachment. + +=item C<grant_group> + +C<int> the group id that is allowed to grant/deny flags of this type. +If the item is not included all users are allowed to grant/deny this +flagtype. + +=item C<request_group> + +C<int> the group id that is allowed to request the flag if the flag +is of the type requestable. If the item is not included all users +are allowed request this flagtype. + +=back + =back =item C<versions> @@ -432,6 +647,11 @@ C<milestones>, C<default_milestone> and C<has_unconfirmed> were added to the fields returned by C<get> as a replacement for C<internals>, which has been removed. +=item REST API call added in Bugzilla B<5.0>. + +=item In Bugzilla B<4.4>, C<flag_types> was added to the fields returned +by C<get>. + =back =back @@ -448,9 +668,16 @@ B<EXPERIMENTAL> This allows you to create a new product in Bugzilla. -=item B<Params> +=item B<REST> -Some params must be set, or an error will be thrown. These params are +POST /product + +The params to include in the POST body as well as the returned data format, +are the same as below. + +=item B<Params> + +Some params must be set, or an error will be thrown. These params are marked B<Required>. =over @@ -464,11 +691,11 @@ within Bugzilla. B<Required> C<string> A description for this product. Allows some simple HTML. -=item C<version> +=item C<version> B<Required> C<string> The default version for this product. -=item C<has_unconfirmed> +=item C<has_unconfirmed> C<boolean> Allow the UNCONFIRMED status to be set on bugs in this product. Default: true. @@ -477,11 +704,11 @@ Default: true. C<string> The name of the Classification which contains this product. -=item C<default_milestone> +=item C<default_milestone> C<string> The default milestone for this product. Default: '---'. -=item C<is_open> +=item C<is_open> C<boolean> True if the product is currently allowing bugs to be entered into it. Default: true. @@ -493,7 +720,7 @@ new product. Default: true. =back -=item B<Returns> +=item B<Returns> A hash with one element, id. This is the id of the newly-filed product. @@ -529,4 +756,12 @@ You must specify a version for this product. =back +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back diff --git a/Bugzilla/WebService/Server.pm b/Bugzilla/WebService/Server.pm index 206f0c657..9727dcbcb 100644 --- a/Bugzilla/WebService/Server.pm +++ b/Bugzilla/WebService/Server.pm @@ -22,6 +22,9 @@ use Bugzilla::Error; use Bugzilla::Util qw(datetime_from); use Scalar::Util qw(blessed); +use Digest::MD5 qw(md5_base64); + +use Storable qw(freeze); sub handle_login { my ($self, $class, $method, $full_method) = @_; @@ -37,7 +40,7 @@ sub handle_login { sub datetime_format_inbound { my ($self, $time) = @_; - + my $converted = datetime_from($time, Bugzilla->local_timezone); if (!defined $converted) { ThrowUserError('illegal_date', { date => $time }); @@ -63,4 +66,71 @@ sub datetime_format_outbound { return $time->iso8601(); } +# ETag support +sub bz_etag { + my ($self, $data) = @_; + my $cache = Bugzilla->request_cache; + if (defined $data) { + # Serialize the data if passed a reference + local $Storable::canonical = 1; + $data = freeze($data) if ref $data; + + # Wide characters cause md5_base64() to die. + utf8::encode($data) if utf8::is_utf8($data); + + # Append content_type to the end of the data + # string as we want the etag to be unique to + # the content_type. We do not need this for + # XMLRPC as text/xml is always returned. + if (blessed($self) && $self->can('content_type')) { + $data .= $self->content_type if $self->content_type; + } + + $cache->{'bz_etag'} = md5_base64($data); + } + return $cache->{'bz_etag'}; +} + 1; + +=head1 NAME + +Bugzilla::WebService::Server - Base server class for the WebService API + +=head1 DESCRIPTION + +Bugzilla::WebService::Server is the base class for the individual WebService API +servers such as XMLRPC, JSONRPC, and REST. You never actually create a +Bugzilla::WebService::Server directly, you only make subclasses of it. + +=head1 FUNCTIONS + +=over + +=item C<bz_etag> + +This function is used to store an ETag value that will be used when returning +the data by the different API server modules such as XMLRPC, or REST. The individual +webservice methods can also set the value earlier in the process if needed such as +before a unique update token is added. If a value is not set earlier, an etag will +automatically be created using the returned data except in some cases when an error +has occurred. + +=back + +=head1 SEE ALSO + +L<Bugzilla::WebService::Server::XMLRPC|XMLRPC>, L<Bugzilla::WebService::Server::JSONRPC|JSONRPC>, +and L<Bugzilla::WebService::Server::REST|REST>. + +=head1 B<Methods in need of POD> + +=over + +=item handle_login + +=item datetime_format_outbound + +=item datetime_format_inbound + +=back diff --git a/Bugzilla/WebService/Server/JSONRPC.pm b/Bugzilla/WebService/Server/JSONRPC.pm index cec1c29ea..109c530b7 100644 --- a/Bugzilla/WebService/Server/JSONRPC.pm +++ b/Bugzilla/WebService/Server/JSONRPC.pm @@ -37,8 +37,8 @@ BEGIN { use Bugzilla::Error; use Bugzilla::WebService::Constants; -use Bugzilla::WebService::Util qw(taint_data); -use Bugzilla::Util qw(correct_urlbase trim disable_utf8); +use Bugzilla::WebService::Util qw(taint_data fix_credentials); +use Bugzilla::Util; use HTTP::Message; use MIME::Base64 qw(decode_base64 encode_base64); @@ -87,12 +87,12 @@ sub response_header { sub response { my ($self, $response) = @_; + my $cgi = $self->cgi; # Implement JSONP. if (my $callback = $self->_bz_callback) { my $content = $response->content; $response->content("$callback($content)"); - } # Use $cgi->header properly instead of just printing text directly. @@ -107,9 +107,18 @@ sub response { push(@header_args, "-$name", $value); } } - my $cgi = $self->cgi; - print $cgi->header(-status => $response->code, @header_args); - print $response->content; + + # ETag support + my $etag = $self->bz_etag; + if ($etag && $cgi->check_etag($etag)) { + push(@header_args, "-ETag", $etag); + print $cgi->header(-status => '304 Not Modified', @header_args); + } + else { + push(@header_args, "-ETag", $etag) if $etag; + print $cgi->header(-status => $response->code, @header_args); + print $response->content; + } } # The JSON-RPC 1.1 GET specification is not so great--you can't specify @@ -221,6 +230,9 @@ sub type { utf8::encode($value) if utf8::is_utf8($value); $retval = encode_base64($value, ''); } + elsif ($type eq 'email' && Bugzilla->params->{'webservice_email_filter'}) { + $retval = email_filter($value); + } return $retval; } @@ -266,7 +278,17 @@ sub _handle { my $self = shift; my ($obj) = @_; $self->{_bz_request_id} = $obj->{id}; - return $self->SUPER::_handle(@_); + + my $result = $self->SUPER::_handle(@_); + + # Set the ETag if not already set in the webservice methods. + my $etag = $self->bz_etag; + if (!$etag && ref $result) { + my $data = $self->json->decode($result)->{'result'}; + $self->bz_etag($data); + } + + return $result; } # Make all error messages returned by JSON::RPC go into the 100000 @@ -363,6 +385,10 @@ sub _argument_type_check { } } + # Update the params to allow for several convenience key/values + # use for authentication + fix_credentials($params); + Bugzilla->input_params($params); if ($self->request->method eq 'POST') { diff --git a/Bugzilla/WebService/Server/REST.pm b/Bugzilla/WebService/Server/REST.pm new file mode 100644 index 000000000..2216911c9 --- /dev/null +++ b/Bugzilla/WebService/Server/REST.pm @@ -0,0 +1,639 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Server::REST; + +use 5.10.1; +use strict; + +use parent qw(Bugzilla::WebService::Server::JSONRPC); + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Util qw(taint_data fix_credentials); +use Bugzilla::Util qw(correct_urlbase html_quote); + +# Load resource modules +use Bugzilla::WebService::Server::REST::Resources::Bug; +use Bugzilla::WebService::Server::REST::Resources::Bugzilla; +use Bugzilla::WebService::Server::REST::Resources::Classification; +use Bugzilla::WebService::Server::REST::Resources::Group; +use Bugzilla::WebService::Server::REST::Resources::Product; +use Bugzilla::WebService::Server::REST::Resources::User; + +use Scalar::Util qw(blessed reftype); +use MIME::Base64 qw(decode_base64); + +########################### +# Public Method Overrides # +########################### + +sub handle { + my ($self) = @_; + + # Determine how the data should be represented. We do this early so + # errors will also be returned with the proper content type. + $self->content_type($self->_best_content_type(REST_CONTENT_TYPE_WHITELIST())); + + # Using current path information, decide which class/method to + # use to serve the request. Throw error if no resource was found + # unless we were looking for OPTIONS + if (!$self->_find_resource($self->cgi->path_info)) { + if ($self->request->method eq 'OPTIONS' + && $self->bz_rest_options) + { + my $response = $self->response_header(STATUS_OK, ""); + my $options_string = join(', ', @{ $self->bz_rest_options }); + $response->header('Allow' => $options_string, + 'Access-Control-Allow-Methods' => $options_string); + return $self->response($response); + } + + ThrowUserError("rest_invalid_resource", + { path => $self->cgi->path_info, + method => $self->request->method }); + } + + # Dispatch to the proper module + my $class = $self->bz_class_name; + my ($path) = $class =~ /::([^:]+)$/; + $self->path_info($path); + delete $self->{dispatch_path}; + $self->dispatch({ $path => $class }); + + my $params = $self->_retrieve_json_params; + + fix_credentials($params); + + # Fix includes/excludes for each call + rest_include_exclude($params); + + # Set callback name if exists + $self->_bz_callback($params->{'callback'}) if $params->{'callback'}; + + Bugzilla->input_params($params); + + # Set the JSON version to 1.1 and the id to the current urlbase + # also set up the correct handler method + my $obj = { + version => '1.1', + id => correct_urlbase(), + method => $self->bz_method_name, + params => $params + }; + + # Execute the handler + my $result = $self->_handle($obj); + + if (!$self->error_response_header) { + return $self->response( + $self->response_header($self->bz_success_code || STATUS_OK, $result)); + } + + $self->response($self->error_response_header); +} + +sub response { + my ($self, $response) = @_; + + # If we have thrown an error, the 'error' key will exist + # otherwise we use 'result'. JSONRPC returns other data + # along with the result/error such as version and id which + # we will strip off for REST calls. + my $content = $response->content; + my $json_data = {}; + if ($content) { + $json_data = $self->json->decode($content); + } + + my $result = {}; + if (exists $json_data->{error}) { + $result = $json_data->{error}; + $result->{error} = $self->type('boolean', 1); + delete $result->{'name'}; # Remove JSONRPCError + } + elsif (exists $json_data->{result}) { + $result = $json_data->{result}; + } + + # Access Control + $response->header("Access-Control-Allow-Origin", "*"); + $response->header("Access-Control-Allow-Headers", "origin, content-type, accept"); + + # ETag support + my $etag = $self->bz_etag; + $self->bz_etag($result) if !$etag; + + # If accessing through web browser, then display in readable format + if ($self->content_type eq 'text/html') { + $result = $self->json->pretty->canonical->allow_nonref->encode($result); + + my $template = Bugzilla->template; + $content = ""; + $template->process("rest.html.tmpl", { result => $result }, \$content) + || ThrowTemplateError($template->error()); + + $response->content_type('text/html'); + } + else { + $content = $self->json->encode($result); + } + + $response->content($content); + + $self->SUPER::response($response); +} + +####################################### +# Bugzilla::WebService Implementation # +####################################### + +sub handle_login { + my $self = shift; + + # If we're being called using GET, we don't allow cookie-based or Env + # login, because GET requests can be done cross-domain, and we don't + # want private data showing up on another site unless the user + # explicitly gives that site their username and password. (This is + # particularly important for JSONP, which would allow a remote site + # to use private data without the user's knowledge, unless we had this + # protection in place.) We do allow this for GET /login as we need to + # for Bugzilla::Auth::Persist::Cookie to create a login cookie that we + # can also use for Bugzilla_token support. This is OK as it requires + # a login and password to be supplied and will fail if they are not + # valid for the user. + if (!grep($_ eq $self->request->method, ('POST', 'PUT')) + && !($self->bz_class_name eq 'Bugzilla::WebService::User' + && $self->bz_method_name eq 'login')) + { + # XXX There's no particularly good way for us to get a parameter + # to Bugzilla->login at this point, so we pass this information + # around using request_cache, which is a bit of a hack. The + # implementation of it is in Bugzilla::Auth::Login::Stack. + Bugzilla->request_cache->{'auth_no_automatic_login'} = 1; + } + + my $class = $self->bz_class_name; + my $method = $self->bz_method_name; + my $full_method = $class . "." . $method; + + # Bypass JSONRPC::handle_login + Bugzilla::WebService::Server->handle_login($class, $method, $full_method); +} + +############################ +# Private Method Overrides # +############################ + +# We do not want to run Bugzilla::WebService::Server::JSONRPC->_find_prodedure +# as it determines the method name differently. +sub _find_procedure { + my $self = shift; + if ($self->isa('JSON::RPC::Server::CGI')) { + return JSON::RPC::Server::_find_procedure($self, @_); + } + else { + return JSON::RPC::Legacy::Server::_find_procedure($self, @_); + } +} + +sub _argument_type_check { + my $self = shift; + my $params; + + if ($self->isa('JSON::RPC::Server::CGI')) { + $params = JSON::RPC::Server::_argument_type_check($self, @_); + } + else { + $params = JSON::RPC::Legacy::Server::_argument_type_check($self, @_); + } + + # JSON-RPC 1.0 requires all parameters to be passed as an array, so + # we just pull out the first item and assume it's an object. + my $params_is_array; + if (ref $params eq 'ARRAY') { + $params = $params->[0]; + $params_is_array = 1; + } + + taint_data($params); + + Bugzilla->input_params($params); + + # Now, convert dateTime fields on input. + my $method = $self->bz_method_name; + my $pkg = $self->{dispatch_path}->{$self->path_info}; + my @date_fields = @{ $pkg->DATE_FIELDS->{$method} || [] }; + foreach my $field (@date_fields) { + if (defined $params->{$field}) { + my $value = $params->{$field}; + if (ref $value eq 'ARRAY') { + $params->{$field} = + [ map { $self->datetime_format_inbound($_) } @$value ]; + } + else { + $params->{$field} = $self->datetime_format_inbound($value); + } + } + } + my @base64_fields = @{ $pkg->BASE64_FIELDS->{$method} || [] }; + foreach my $field (@base64_fields) { + if (defined $params->{$field}) { + $params->{$field} = decode_base64($params->{$field}); + } + } + + # This is the best time to do login checks. + $self->handle_login(); + + # Bugzilla::WebService packages call internal methods like + # $self->_some_private_method. So we have to inherit from + # that class as well as this Server class. + my $new_class = ref($self) . '::' . $pkg; + my $isa_string = 'our @ISA = qw(' . ref($self) . " $pkg)"; + eval "package $new_class;$isa_string;"; + bless $self, $new_class; + + if ($params_is_array) { + $params = [$params]; + } + + return $params; +} + +################### +# Utility Methods # +################### + +sub bz_method_name { + my ($self, $method) = @_; + $self->{_bz_method_name} = $method if $method; + return $self->{_bz_method_name}; +} + +sub bz_class_name { + my ($self, $class) = @_; + $self->{_bz_class_name} = $class if $class; + return $self->{_bz_class_name}; +} + +sub bz_success_code { + my ($self, $value) = @_; + $self->{_bz_success_code} = $value if $value; + return $self->{_bz_success_code}; +} + +sub bz_rest_params { + my ($self, $params) = @_; + $self->{_bz_rest_params} = $params if $params; + return $self->{_bz_rest_params}; +} + +sub bz_rest_options { + my ($self, $options) = @_; + $self->{_bz_rest_options} = $options if $options; + return $self->{_bz_rest_options}; +} + +sub rest_include_exclude { + my ($params) = @_; + + # _all is same as default columns + if ($params->{'include_fields'} + && ($params->{'include_fields'} eq '_all' + || $params->{'include_fields'} eq '_default')) + { + delete $params->{'include_fields'}; + delete $params->{'exclude_fields'} if $params->{'exclude_fields'}; + } + + if ($params->{'include_fields'} && !ref $params->{'include_fields'}) { + $params->{'include_fields'} = [ split(/[\s+,]/, $params->{'include_fields'}) ]; + } + if ($params->{'exclude_fields'} && !ref $params->{'exclude_fields'}) { + $params->{'exclude_fields'} = [ split(/[\s+,]/, $params->{'exclude_fields'}) ]; + } + + return $params; +} + +########################## +# Private Custom Methods # +########################## + +sub _retrieve_json_params { + my $self = shift; + + # Make a copy of the current input_params rather than edit directly + my $params = {}; + %{$params} = %{ Bugzilla->input_params }; + + # First add any params we were able to pull out of the path + # based on the resource regexp + %{$params} = (%{$params}, %{$self->bz_rest_params}) if $self->bz_rest_params; + + # Merge any additional query key/values with $obj->{params} if not a GET request + # We do this manually cause CGI.pm doesn't understand JSON strings. + if ($self->request->method ne 'GET') { + my $extra_params = {}; + my $json = delete $params->{'POSTDATA'} || delete $params->{'PUTDATA'}; + if ($json) { + eval { $extra_params = $self->json->decode($json); }; + if ($@) { + ThrowUserError('json_rpc_invalid_params', { err_msg => $@ }); + } + } + + # Allow parameters in the query string if request was not GET. + # Note: query string parameters will override any matching params + # also specified in the request body. + foreach my $param ($self->cgi->url_param()) { + $extra_params->{$param} = $self->cgi->url_param($param); + } + + %{$params} = (%{$params}, %{$extra_params}) if %{$extra_params}; + } + + return $params; +} + +sub _find_resource { + my ($self, $path) = @_; + + # Load in the WebService module from the dispatch map and then call + # $module->rest_resources to get the resources array ref. + my $resources = {}; + foreach my $module (values %{ $self->{dispatch_path} }) { + eval("require $module") || die $@; + next if !$module->can('rest_resources'); + $resources->{$module} = $module->rest_resources; + } + + # Use the resources hash from each module loaded earlier to determine + # which handler to use based on a regex match of the CGI path. + # Also any matches found in the regex will be passed in later to the + # handler for possible use. + my $request_method = $self->request->method; + + my (@matches, $handler_found, $handler_method, $handler_class); + foreach my $class (keys %{ $resources }) { + # The resource data for each module needs to be + # an array ref with an even number of elements + # to work correctly. + next if (ref $resources->{$class} ne 'ARRAY' + || scalar @{ $resources->{$class} } % 2 != 0); + + while (my $regex = shift @{ $resources->{$class} }) { + my $options_data = shift @{ $resources->{$class} }; + next if ref $options_data ne 'HASH'; + + if (@matches = ($path =~ $regex)) { + # If a specific path is accompanied by a OPTIONS request + # method, the user is asking for a list of possible request + # methods for a specific path. + $self->bz_rest_options([ keys %{ $options_data } ]); + + if ($options_data->{$request_method}) { + my $resource_data = $options_data->{$request_method}; + $self->bz_class_name($class); + + # The method key/value can be a simple scalar method name + # or a anonymous subroutine so we execute it here. + my $method = ref $resource_data->{method} eq 'CODE' + ? $resource_data->{method}->($self) + : $resource_data->{method}; + $self->bz_method_name($method); + + # Pull out any parameters parsed from the URL path + # and store them for use by the method. + if ($resource_data->{params}) { + $self->bz_rest_params($resource_data->{params}->(@matches)); + } + + # If a special success code is needed for this particular + # method, then store it for later when generating response. + if ($resource_data->{success_code}) { + $self->bz_success_code($resource_data->{success_code}); + } + $handler_found = 1; + } + } + last if $handler_found; + } + last if $handler_found; + } + + return $handler_found; +} + +sub _best_content_type { + my ($self, @types) = @_; + return ($self->_simple_content_negotiation(@types))[0] || '*/*'; +} + +sub _simple_content_negotiation { + my ($self, @types) = @_; + my @accept_types = $self->_get_content_prefs(); + my $score = sub { $self->_score_type(shift, @accept_types) }; + return sort {$score->($b) <=> $score->($a)} @types; +} + +sub _score_type { + my ($self, $type, @accept_types) = @_; + my $score = scalar(@accept_types); + for my $accept_type (@accept_types) { + return $score if $type eq $accept_type; + $score--; + } + return 0; +} + +sub _get_content_prefs { + my $self = shift; + my $default_weight = 1; + my @prefs; + + # Parse the Accept header, and save type name, score, and position. + my @accept_types = split /,/, $self->cgi->http('accept') || ''; + my $order = 0; + for my $accept_type (@accept_types) { + my ($weight) = ($accept_type =~ /q=(\d\.\d+|\d+)/); + my ($name) = ($accept_type =~ m#(\S+/[^;]+)#); + next unless $name; + push @prefs, { name => $name, order => $order++}; + if (defined $weight) { + $prefs[-1]->{score} = $weight; + } else { + $prefs[-1]->{score} = $default_weight; + $default_weight -= 0.001; + } + } + + # Sort the types by score, subscore by order, and pull out just the name + @prefs = map {$_->{name}} sort {$b->{score} <=> $a->{score} || + $a->{order} <=> $b->{order}} @prefs; + return @prefs, '*/*'; # Allows allow for */* +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::WebService::Server::REST - The REST Interface to Bugzilla + +=head1 DESCRIPTION + +This documentation describes things about the Bugzilla WebService that +are specific to REST. For a general overview of the Bugzilla WebServices, +see L<Bugzilla::WebService>. The L<Bugzilla::WebService::Server::REST> +module is a sub-class of L<Bugzilla::WebService::Server::JSONRPC> so any +method documentation not found here can be viewed in it's POD. + +Please note that I<everything> about this REST interface is +B<EXPERIMENTAL>. If you want a fully stable API, please use the +C<Bugzilla::WebService::Server::XMLRPC|XML-RPC> interface. + +=head1 CONNECTING + +The endpoint for the REST interface is the C<rest.cgi> script in +your Bugzilla installation. If using Apache and mod_rewrite is installed +and enabled, you can also use /rest/ as your endpoint. For example, if your +Bugzilla is at C<bugzilla.yourdomain.com>, then your REST client would +access the API via: C<http://bugzilla.yourdomain.com/rest/bug/35> which +looks cleaner. + +=head1 BROWSING + +If the Accept: header of a request is set to text/html (as it is by an +ordinary web browser) then the API will return the JSON data as a HTML +page which the browser can display. In other words, you can play with the +API using just your browser and see results in a human-readable form. +This is a good way to try out the various GET calls, even if you can't use +it for POST or PUT. + +=head1 DATA FORMAT + +The REST API only supports JSON input, and either JSON and JSONP output. +So objects sent and received must be in JSON format. Basically since +the REST API is a sub class of the JSONRPC API, you can refer to +L<JSONRPC|Bugzilla::WebService::Server::JSONRPC> for more information +on data types that are valid for REST. + +On every request, you must set both the "Accept" and "Content-Type" HTTP +headers to the MIME type of the data format you are using to communicate with +the API. Content-Type tells the API how to interpret your request, and Accept +tells it how you want your data back. "Content-Type" must be "application/json". +"Accept" can be either that, or "application/javascript" for JSONP - add a "callback" +parameter to name your callback. + +Parameters may also be passed in as part of the query string for non-GET requests +and will override any matching parameters in the request body. + +=head1 AUTHENTICATION + +Along with viewing data as an anonymous user, you may also see private information +if you have a Bugzilla account by providing your login credentials. + +=over + +=item Login name and password + +Pass in as query parameters of any request: + +login=fred@example.com&password=ilovecheese + +Remember to URL encode any special characters, which are often seen in passwords and to +also enable SSL support. + +=item Login token + +By calling GET /login?login=fred@example.com&password=ilovecheese, you get back +a C<token> value which can then be passed to each subsequent call as +authentication. This is useful for third party clients that cannot use cookies +and do not want to store a user's login and password in the client. You can also +pass in "token" as a convenience. + +=back + +=head1 ERRORS + +When an error occurs over REST, a hash structure is returned with the key C<error> +set to C<true>. + +The error contents look similar to: + + { "error": true, "message": "Some message here", "code": 123 } + +Every error has a "code", as described in L<Bugzilla::WebService/ERRORS>. +Errors with a numeric C<code> higher than 100000 are errors thrown by +the JSON-RPC library that Bugzilla uses, not by Bugzilla. + +=head1 UTILITY FUNCTIONS + +=over + +=item B<handle> + +This method overrides the handle method provided by JSONRPC so that certain +actions related to REST such as determining the proper resource to use, +loading query parameters, etc. can be done before the proper WebService +method is executed. + +=item B<response> + +This method overrides the response method provided by JSONRPC so that +the response content can be altered for REST before being returned to +the client. + +=item B<handle_login> + +This method determines the proper WebService all to make based on class +and method name determined earlier. Then calls L<Bugzilla::WebService::Server::handle_login> +which will attempt to authenticate the client. + +=item B<bz_method_name> + +The WebService method name that matches the path used by the client. + +=item B<bz_class_name> + +The WebService class containing the method that matches the path used by the client. + +=item B<bz_rest_params> + +Each REST resource contains a hash key called C<params> that is a subroutine reference. +This subroutine will return a hash structure based on matched values from the path +information that is formatted properly for the WebService method that will be called. + +=item B<bz_rest_options> + +When a client uses the OPTIONS request method along with a specific path, they are +requesting the list of request methods that are valid for the path. Such as for the +path /bug, the valid request methods are GET (search) and POST (create). So the +client would receive in the response header, C<Access-Control-Allow-Methods: GET, POST>. + +=item B<bz_success_code> + +Each resource can specify a specific SUCCESS CODE if the operation completes successfully. +OTherwise STATUS OK (200) is the default returned. + +=item B<rest_include_exclude> + +Normally the WebService methods required C<include_fields> and C<exclude_fields> to be an +array of field names. REST allows for the values for these to be instead comma delimited +string of field names. This method converts the latter into the former so the WebService +methods will not complain. + +=back + +=head1 SEE ALSO + +L<Bugzilla::WebService> diff --git a/Bugzilla/WebService/Server/REST/Resources/Bug.pm b/Bugzilla/WebService/Server/REST/Resources/Bug.pm new file mode 100644 index 000000000..ea420b4ed --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Bug.pm @@ -0,0 +1,174 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Server::REST::Resources::Bug; + +use 5.10.1; +use strict; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Bug; + +BEGIN { + *Bugzilla::WebService::Bug::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/bug$}, { + GET => { + method => 'search', + }, + POST => { + method => 'create', + status_code => STATUS_CREATED + } + }, + qr{^/bug/([^/]+)$}, { + GET => { + method => 'get', + params => sub { + return { ids => [ $_[0] ] }; + } + }, + PUT => { + method => 'update', + params => sub { + return { ids => [ $_[0] ] }; + } + } + }, + qr{^/bug/([^/]+)/comment$}, { + GET => { + method => 'comments', + params => sub { + return { ids => [ $_[0] ] }; + } + }, + POST => { + method => 'add_comment', + params => sub { + return { id => $_[0] }; + }, + success_code => STATUS_CREATED + } + }, + qr{^/bug/comment/([^/]+)$}, { + GET => { + method => 'comments', + params => sub { + return { comment_ids => [ $_[0] ] }; + } + } + }, + qr{^/bug/comment/tags/([^/]+)$}, { + GET => { + method => 'search_comment_tags', + params => sub { + return { query => $_[0] }; + }, + }, + }, + qr{^/bug/comment/([^/]+)/tags$}, { + PUT => { + method => 'update_comment_tags', + params => sub { + return { comment_id => $_[0] }; + }, + }, + }, + qr{^/bug/([^/]+)/history$}, { + GET => { + method => 'history', + params => sub { + return { ids => [ $_[0] ] }; + }, + } + }, + qr{^/bug/([^/]+)/attachment$}, { + GET => { + method => 'attachments', + params => sub { + return { ids => [ $_[0] ] }; + } + }, + POST => { + method => 'add_attachment', + params => sub { + return { ids => [ $_[0] ] }; + }, + success_code => STATUS_CREATED + } + }, + qr{^/bug/attachment/([^/]+)$}, { + GET => { + method => 'attachments', + params => sub { + return { attachment_ids => [ $_[0] ] }; + } + }, + PUT => { + method => 'update_attachment', + params => sub { + return { ids => [ $_[0] ] }; + } + } + }, + qr{^/field/bug$}, { + GET => { + method => 'fields', + } + }, + qr{^/field/bug/([^/]+)$}, { + GET => { + method => 'fields', + params => sub { + my $value = $_[0]; + my $param = 'names'; + $param = 'ids' if $value =~ /^\d+$/; + return { $param => [ $_[0] ] }; + } + } + }, + qr{^/field/bug/([^/]+)/values$}, { + GET => { + method => 'legal_values', + params => sub { + return { field => $_[0] }; + } + } + }, + qr{^/field/bug/([^/]+)/([^/]+)/values$}, { + GET => { + method => 'legal_values', + params => sub { + return { field => $_[0], + product_id => $_[1] }; + } + } + }, + + ]; + return $rest_resources; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Server::REST::Resources::Bug - The REST API for creating, +changing, and getting the details of bugs. + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to file a new bug in Bugzilla, +or get information about bugs that have already been filed. + +See L<Bugzilla::WebService::Bug> for more details on how to use this part of +the REST API. diff --git a/Bugzilla/WebService/Server/REST/Resources/Bugzilla.pm b/Bugzilla/WebService/Server/REST/Resources/Bugzilla.pm new file mode 100644 index 000000000..1c86f77bc --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Bugzilla.pm @@ -0,0 +1,69 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Server::REST::Resources::Bugzilla; + +use 5.10.1; +use strict; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Bugzilla; + +BEGIN { + *Bugzilla::WebService::Bugzilla::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/version$}, { + GET => { + method => 'version' + } + }, + qr{^/extensions$}, { + GET => { + method => 'extensions' + } + }, + qr{^/timezone$}, { + GET => { + method => 'timezone' + } + }, + qr{^/time$}, { + GET => { + method => 'time' + } + }, + qr{^/last_audit_time$}, { + GET => { + method => 'last_audit_time' + } + }, + qr{^/parameters$}, { + GET => { + method => 'parameters' + } + } + ]; + return $rest_resources; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::WebService::Bugzilla - Global functions for the webservice interface. + +=head1 DESCRIPTION + +This provides functions that tell you about Bugzilla in general. + +See L<Bugzilla::WebService::Bugzilla> for more details on how to use this part +of the REST API. diff --git a/Bugzilla/WebService/Server/REST/Resources/Classification.pm b/Bugzilla/WebService/Server/REST/Resources/Classification.pm new file mode 100644 index 000000000..5bb697ac1 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Classification.pm @@ -0,0 +1,49 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Server::REST::Resources::Classification; + +use 5.10.1; +use strict; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Classification; + +BEGIN { + *Bugzilla::WebService::Classification::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/classification/([^/]+)$}, { + GET => { + method => 'get', + params => sub { + my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; + return { $param => [ $_[0] ] }; + } + } + } + ]; + return $rest_resources; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Server::REST::Resources::Classification - The Classification REST API + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to deal with the available Classifications. +You will be able to get information about them as well as manipulate them. + +See L<Bugzilla::WebService::Bug> for more details on how to use this part +of the REST API. diff --git a/Bugzilla/WebService/Server/REST/Resources/Group.pm b/Bugzilla/WebService/Server/REST/Resources/Group.pm new file mode 100644 index 000000000..9200d609d --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Group.pm @@ -0,0 +1,56 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Server::REST::Resources::Group; + +use 5.10.1; +use strict; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Group; + +BEGIN { + *Bugzilla::WebService::Group::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/group$}, { + POST => { + method => 'create', + success_code => STATUS_CREATED + } + }, + qr{^/group/([^/]+)$}, { + PUT => { + method => 'update', + params => sub { + my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; + return { $param => [ $_[0] ] }; + } + } + } + ]; + return $rest_resources; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Server::REST::Resources::Group - The REST API for +creating, changing, and getting information about Groups. + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to create Groups and +get information about them. + +See L<Bugzilla::WebService::Group> for more details on how to use this part +of the REST API. diff --git a/Bugzilla/WebService/Server/REST/Resources/Product.pm b/Bugzilla/WebService/Server/REST/Resources/Product.pm new file mode 100644 index 000000000..acee3887b --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Product.pm @@ -0,0 +1,82 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Server::REST::Resources::Product; + +use 5.10.1; +use strict; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Product; + +use Bugzilla::Error; + +BEGIN { + *Bugzilla::WebService::Product::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/product_accessible$}, { + GET => { + method => 'get_accessible_products' + } + }, + qr{^/product_enterable$}, { + GET => { + method => 'get_enterable_products' + } + }, + qr{^/product_selectable$}, { + GET => { + method => 'get_selectable_products' + } + }, + qr{^/product$}, { + GET => { + method => 'get' + }, + POST => { + method => 'create', + success_code => STATUS_CREATED + } + }, + qr{^/product/([^/]+)$}, { + GET => { + method => 'get', + params => sub { + my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; + return { $param => [ $_[0] ] }; + } + }, + PUT => { + method => 'update', + params => sub { + my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; + return { $param => [ $_[0] ] }; + } + } + }, + ]; + return $rest_resources; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Server::REST::Resources::Product - The Product REST API + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to list the available Products and +get information about them. + +See L<Bugzilla::WebService::Bug> for more details on how to use this part of +the REST API. diff --git a/Bugzilla/WebService/Server/REST/Resources/User.pm b/Bugzilla/WebService/Server/REST/Resources/User.pm new file mode 100644 index 000000000..badbc94b2 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/User.pm @@ -0,0 +1,80 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::WebService::Server::REST::Resources::User; + +use 5.10.1; +use strict; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::User; + +BEGIN { + *Bugzilla::WebService::User::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/valid_login$}, { + GET => { + method => 'valid_login' + } + }, + qr{^/login$}, { + GET => { + method => 'login' + } + }, + qr{^/logout$}, { + GET => { + method => 'logout' + } + }, + qr{^/user$}, { + GET => { + method => 'get' + }, + POST => { + method => 'create', + success_code => STATUS_CREATED + } + }, + qr{^/user/([^/]+)$}, { + GET => { + method => 'get', + params => sub { + my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; + return { $param => [ $_[0] ] }; + } + }, + PUT => { + method => 'update', + params => sub { + my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; + return { $param => [ $_[0] ] }; + } + } + } + ]; + return $rest_resources; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Server::REST::Resources::User - The User Account REST API + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to get User information as well +as create User Accounts. + +See L<Bugzilla::WebService::Bug> for more details on how to use this part of +the REST API. diff --git a/Bugzilla/WebService/Server/XMLRPC.pm b/Bugzilla/WebService/Server/XMLRPC.pm index fc297421a..8d9108122 100644 --- a/Bugzilla/WebService/Server/XMLRPC.pm +++ b/Bugzilla/WebService/Server/XMLRPC.pm @@ -30,9 +30,10 @@ if ($ENV{MOD_PERL}) { } use Bugzilla::WebService::Constants; +use Bugzilla::Util; -# Allow WebService methods to call XMLRPC::Lite's type method directly BEGIN { + # Allow WebService methods to call XMLRPC::Lite's type method directly *Bugzilla::WebService::type = sub { my ($self, $type, $value) = @_; if ($type eq 'dateTime') { @@ -41,8 +42,19 @@ BEGIN { $value = Bugzilla::WebService::Server->datetime_format_outbound($value); $value =~ s/-//g; } + elsif ($type eq 'email') { + $type = 'string'; + if (Bugzilla->params->{'webservice_email_filter'}) { + $value = email_filter($value); + } + } return XMLRPC::Data->type($type)->value($value); }; + + # Add support for ETags into XMLRPC WebServices + *Bugzilla::WebService::bz_etag = sub { + return Bugzilla::WebService::Server->bz_etag($_[1]); + }; } sub initialize { @@ -56,22 +68,38 @@ sub initialize { sub make_response { my $self = shift; + my $cgi = Bugzilla->cgi; $self->SUPER::make_response(@_); # XMLRPC::Transport::HTTP::CGI doesn't know about Bugzilla carrying around # its cookies in Bugzilla::CGI, so we need to copy them over. - foreach my $cookie (@{Bugzilla->cgi->{'Bugzilla_cookie_list'}}) { + foreach my $cookie (@{$cgi->{'Bugzilla_cookie_list'}}) { $self->response->headers->push_header('Set-Cookie', $cookie); } # Copy across security related headers from Bugzilla::CGI - foreach my $header (split(/[\r\n]+/, Bugzilla->cgi->header)) { + foreach my $header (split(/[\r\n]+/, $cgi->header)) { my ($name, $value) = $header =~ /^([^:]+): (.*)/; if (!$self->response->headers->header($name)) { $self->response->headers->header($name => $value); } } + + # ETag support + my $etag = $self->bz_etag; + if (!$etag) { + my $data = $self->response->as_string; + $etag = $self->bz_etag($data); + } + + if ($etag && $cgi->check_etag($etag)) { + $self->response->headers->push_header('ETag', $etag); + $self->response->headers->push_header('status', '304 Not Modified'); + } + elsif ($etag) { + $self->response->headers->push_header('ETag', $etag); + } } sub handle_login { diff --git a/Bugzilla/WebService/User.pm b/Bugzilla/WebService/User.pm index deb7518ec..7aa1a626b 100644 --- a/Bugzilla/WebService/User.pm +++ b/Bugzilla/WebService/User.pm @@ -29,6 +29,9 @@ use Bugzilla::Group; use Bugzilla::User; use Bugzilla::Util qw(trim); use Bugzilla::WebService::Util qw(filter validate); +use Bugzilla::Hook; + +use List::Util qw(first); # Don't need auth to login use constant LOGIN_EXEMPT => { @@ -70,14 +73,36 @@ sub login { $input_params->{'Bugzilla_password'} = $params->{password}; $input_params->{'Bugzilla_remember'} = $remember; - Bugzilla->login(); - return { id => $self->type('int', Bugzilla->user->id) }; + my $user = Bugzilla->login(); + + my $result = { id => $self->type('int', $user->id) }; + + # We will use the stored cookie value combined with the user id + # to create a token that can be used with future requests in the + # query parameters + my $login_cookie = first { $_->name eq 'Bugzilla_logincookie' } + @{ Bugzilla->cgi->{'Bugzilla_cookie_list'} }; + if ($login_cookie) { + $result->{'token'} = $user->id . "-" . $login_cookie->value; + } + + return $result; } sub logout { my $self = shift; Bugzilla->logout; - return undef; +} + +sub valid_login { + my ($self, $params) = @_; + defined $params->{login} + || ThrowCodeError('param_required', { param => 'login' }); + Bugzilla->login(); + if (Bugzilla->user->id && Bugzilla->user->login eq $params->{login}) { + return $self->type('boolean', 1); + } + return $self->type('boolean', 0); } ################# @@ -124,7 +149,9 @@ sub create { # $call = $rpc->call( 'User.get', { ids => [1,2,3], # names => ['testusera@redhat.com', 'testuserb@redhat.com'] }); sub get { - my ($self, $params) = validate(@_, 'names', 'ids'); + my ($self, $params) = validate(@_, 'names', 'ids', 'match', 'group_ids', 'groups'); + + Bugzilla->switch_to_shadow_db(); defined($params->{names}) || defined($params->{ids}) || defined($params->{match}) @@ -154,8 +181,8 @@ sub get { \@user_objects, $params); @users = map {filter $params, { id => $self->type('int', $_->id), - real_name => $self->type('string', $_->name), - name => $self->type('string', $_->login), + real_name => $self->type('string', $_->name), + name => $self->type('email', $_->login), }} @$in_group; return { users => \@users }; @@ -196,33 +223,39 @@ sub get { } } } - + my $in_group = $self->_filter_users_by_group( \@user_objects, $params); if (Bugzilla->user->in_group('editusers')) { - @users = + @users = map {filter $params, { id => $self->type('int', $_->id), real_name => $self->type('string', $_->name), - name => $self->type('string', $_->login), - email => $self->type('string', $_->email), + name => $self->type('email', $_->login), + email => $self->type('email', $_->email), can_login => $self->type('boolean', $_->is_enabled ? 1 : 0), + groups => $self->_filter_bless_groups($_->groups), email_enabled => $self->type('boolean', $_->email_enabled), login_denied_text => $self->type('string', $_->disabledtext), + saved_searches => [map { $self->_query_to_hash($_) } @{ $_->queries }], }} @$in_group; - } else { @users = map {filter $params, { id => $self->type('int', $_->id), real_name => $self->type('string', $_->name), - name => $self->type('string', $_->login), - email => $self->type('string', $_->email), + name => $self->type('email', $_->login), + email => $self->type('email', $_->email), can_login => $self->type('boolean', $_->is_enabled ? 1 : 0), + groups => $self->_filter_bless_groups($_->groups), + saved_searches => [map { $self->_query_to_hash($_) } @{ $_->queries }], }} @$in_group; } + Bugzilla::Hook::process('webservice_user_get', + { webservice => $self, params => $params, users => \@users }); + return { users => \@users }; } @@ -259,6 +292,40 @@ sub _user_in_any_group { return 0; } +sub _filter_bless_groups { + my ($self, $groups) = @_; + my $user = Bugzilla->user; + + my @filtered_groups; + foreach my $group (@$groups) { + next unless ($user->in_group('editusers') || $user->can_bless($group->id)); + push(@filtered_groups, $self->_group_to_hash($group)); + } + + return \@filtered_groups; +} + +sub _group_to_hash { + my ($self, $group) = @_; + my $item = { + id => $self->type('int', $group->id), + name => $self->type('string', $group->name), + description => $self->type('string', $group->description), + }; + return $item; +} + +sub _query_to_hash { + my ($self, $query) = @_; + my $item = { + id => $self->type('int', $query->id), + name => $self->type('string', $query->name), + url => $self->type('string', $query->url), + }; + + return $item; +} + 1; __END__ @@ -277,6 +344,10 @@ log in/out using an existing account. See L<Bugzilla::WebService> for a description of how parameters are passed, and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. +Although the data input and output is the same for JSONRPC, XMLRPC and REST, +the directions for how to access the data via REST is noted in each method +where applicable. + =head1 Logging In and Out =head2 login @@ -295,7 +366,7 @@ etc. This method logs in an user. =over -=item C<login> (string) - The user's login name. +=item C<login> (string) - The user's login name. =item C<password> (string) - The user's password. @@ -311,10 +382,14 @@ management of cookies across sessions. =item B<Returns> -On success, a hash containing one item, C<id>, the numeric id of the -user that was logged in. A set of http cookies is also sent with the -response. These cookies must be sent along with any future requests -to the webservice, for the duration of the session. +On success, a hash containing two items, C<id>, the numeric id of the +user that was logged in, and a C<token> which can be passed in +the parameters as authentication in other calls. A set of http cookies +is also sent with the response. These cookies *or* the token can be sent +along with any future requests to the webservice, for the duration of the +session. Note that cookies are not accepted for GET requests for JSONRPC +and REST for security reasons. You may, however, use the token or valid +login parameters for those requests. =item B<Errors> @@ -360,6 +435,50 @@ Log out the user. Does nothing if there is no user logged in. =back +=head2 valid_login + +B<UNSTABLE> + +=over + +=item B<Description> + +This method will verify whether a client's cookies or current login +token is still valid or have expired. A valid username must be provided +as well that matches. + +=item B<Params> + +=over + +=item C<login> + +The login name that matches the provided cookies or token. + +=item C<token> + +(string) Persistent login token current being used for authentication (optional). +Cookies passed by client will be used before the token if both provided. + +=back + +=item B<Returns> + +Returns true/false depending on if the current cookies or token are valid +for the provided username. + +=item B<Errors> (none) + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back + =head1 Account Creation =head2 offer_account_by_email @@ -419,6 +538,13 @@ actually receive an email. This function does not check that. You must be logged in and have the C<editusers> privilege in order to call this function. +=item B<REST> + +POST /user + +The params to include in the POST body as well as the returned data format, +are the same as below. + =item B<Params> =over @@ -462,6 +588,8 @@ password is under three characters.) =item Error 503 (Password Too Long) removed in Bugzilla B<3.6>. +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -478,6 +606,18 @@ B<STABLE> Gets information about user accounts in Bugzilla. +=item B<REST> + +To get information about a single user: + +GET /user/<user_id_or_name> + +To search for users by name, group using URL params same as below: + +GET /user + +The returned data format is the same as below. + =item B<Params> B<Note>: At least one of C<ids>, C<names>, or C<match> must be specified. @@ -539,6 +679,14 @@ match string. Setting C<include_disabled> to C<true> will include disabled users in the returned results even if their username doesn't fully match the input string. +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =item B<Returns> @@ -581,10 +729,60 @@ C<string> A text field that holds the reason for disabling a user from logging into bugzilla, if empty then the user account is enabled. Otherwise it is disabled/closed. +=item groups + +C<array> An array of group hashes the user is a member of. Each hash describes +the group and contains the following items: + +=over + +=item id + +C<int> The group id + +=item name + +C<string> The name of the group + +=item description + +C<string> The description for the group + +=back + +=over + +=item saved_searches + +C<array> An array of hashes, each of which represents a user's saved search and has +the following keys: + +=over + +=item id + +C<int> An integer id uniquely identifying the saved search. + +=item name + +C<string> The name of the saved search. + +=item url + +C<string> The CGI parameters for the saved search. + +=back + +B<Note>: The elements of the returned array (i.e. hashes) are ordered by the +name of each saved search. + +=back + B<Note>: If you are not logged in to Bugzilla when you call this function, you will only be returned the C<id>, C<name>, and C<real_name> items. If you are logged in and not in editusers group, you will only be returned the C<id>, C<name>, -C<real_name>, C<email>, and C<can_login> items. +C<real_name>, C<email>, and C<can_login> items. The groups returned are filtered +based on your permission to bless each group. =back @@ -625,9 +823,15 @@ exist or you do not belong to it. =item C<include_disabled> added in Bugzilla B<4.0>. Default behavior for C<match> has changed to only returning enabled accounts. +=item C<groups> Added in Bugzilla B<4.4>. + +=item C<saved_searches> Added in Bugzilla B<4.4>. + =item Error 804 has been added in Bugzilla 4.0.9 and 4.2.4. It's now illegal to pass a group name you don't belong to. =back +=item REST API call added in Bugzilla B<5.0>. + =back diff --git a/Bugzilla/WebService/Util.pm b/Bugzilla/WebService/Util.pm index fe4105ca2..a0421ad9a 100644 --- a/Bugzilla/WebService/Util.pm +++ b/Bugzilla/WebService/Util.pm @@ -32,32 +32,56 @@ our @EXPORT_OK = qw( filter_wants taint_data validate + translate + params_to_objects + fix_credentials ); -sub filter ($$) { - my ($params, $hash) = @_; +sub filter ($$;$) { + my ($params, $hash, $prefix) = @_; my %newhash = %$hash; foreach my $key (keys %$hash) { - delete $newhash{$key} if !filter_wants($params, $key); + delete $newhash{$key} if !filter_wants($params, $key, $prefix); } return \%newhash; } -sub filter_wants ($$) { - my ($params, $field) = @_; +sub filter_wants ($$;$) { + my ($params, $field, $prefix) = @_; + + # Since this is operation is resource intensive, we will cache the results + # This assumes that $params->{*_fields} doesn't change between calls + my $cache = Bugzilla->request_cache->{filter_wants} ||= {}; + $field = "${prefix}.${field}" if $prefix; + + if (exists $cache->{$field}) { + return $cache->{$field}; + } + my %include = map { $_ => 1 } @{ $params->{'include_fields'} || [] }; my %exclude = map { $_ => 1 } @{ $params->{'exclude_fields'} || [] }; - if (defined $params->{include_fields}) { - return 0 if !$include{$field}; + my $wants = 1; + if (defined $params->{exclude_fields} && $exclude{$field}) { + $wants = 0; } - if (defined $params->{exclude_fields}) { - return 0 if $exclude{$field}; + elsif (defined $params->{include_fields} && !$include{$field}) { + if ($prefix) { + # Include the field if the parent is include (and this one is not excluded) + $wants = 0 if !$include{$prefix}; + } + else { + # We want to include this if one of the sub keys is included + my $key = $field . '.'; + my $len = length($key); + $wants = 0 if ! grep { substr($_, 0, $len) eq $key } keys %include; + } } - return 1; + $cache->{$field} = $wants; + return $wants; } sub taint_data { @@ -108,6 +132,48 @@ sub validate { return ($self, $params); } +sub translate { + my ($params, $mapped) = @_; + my %changes; + while (my ($key,$value) = each (%$params)) { + my $new_field = $mapped->{$key} || $key; + $changes{$new_field} = $value; + } + return \%changes; +} + +sub params_to_objects { + my ($params, $class) = @_; + my (@objects, @objects_by_ids); + + @objects = map { $class->check($_) } + @{ $params->{names} } if $params->{names}; + + @objects_by_ids = map { $class->check({ id => $_ }) } + @{ $params->{ids} } if $params->{ids}; + + push(@objects, @objects_by_ids); + my %seen; + @objects = grep { !$seen{$_->id}++ } @objects; + return \@objects; +} + +sub fix_credentials { + my ($params) = @_; + # Allow user to pass in login=foo&password=bar as a convenience + # even if not calling GET /login. We also do not delete them as + # GET /login requires "login" and "password". + if (exists $params->{'login'} && exists $params->{'password'}) { + $params->{'Bugzilla_login'} = $params->{'login'}; + $params->{'Bugzilla_password'} = $params->{'password'}; + } + # Allow user to pass token=12345678 as a convenience which becomes + # "Bugzilla_token" which is what the auth code looks for. + if (exists $params->{'token'}) { + $params->{'Bugzilla_token'} = $params->{'token'}; + } +} + __END__ =head1 NAME @@ -136,6 +202,13 @@ of WebService methods. Given a hash (the second argument to this subroutine), this will remove any keys that are I<not> in C<include_fields> and then remove any keys that I<are> in C<exclude_fields>. +An optional third option can be passed that prefixes the field name to allow +filtering of data two or more levels deep. + +For example, if you want to filter out the C<id> key/value in components returned +by Product.get, you would use the value C<component.id> in your C<exclude_fields> +list. + =head2 filter_wants Returns C<1> if a filter would preserve the specified field when passing @@ -147,3 +220,25 @@ This helps in the validation of parameters passed into the WebService methods. Currently it converts listed parameters into an array reference if the client only passed a single scalar value. It modifies the parameters hash in place so other parameters should be unaltered. + +=head2 params_to_objects + +Creates objects of the type passed in as the second parameter, using the +parameters passed to a WebService method (the first parameter to this function). +Helps make life simpler for WebService methods that internally create objects +via both "ids" and "names" fields. Also de-duplicates objects that were loaded +by both "ids" and "names". Returns an arrayref of objects. + +=head2 fix_credentials + +Allows for certain parameters related to authentication such as Bugzilla_login, +Bugzilla_password, and Bugzilla_token to have shorter named equivalents passed in. +This function converts the shorter versions to their respective internal names. + +=head1 B<Methods in need of POD> + +=over + +=item taint_data + +=back |