diff options
author | Christian Ruppert <idl0r@gentoo.org> | 2015-07-11 15:27:33 +0200 |
---|---|---|
committer | Christian Ruppert <idl0r@gentoo.org> | 2015-07-11 15:27:33 +0200 |
commit | 4b2ce2725e9a4525e273fb1b08243aad74770a3d (patch) | |
tree | c90fb0fef5c9ac923817f908f1c3586efa47ec59 /Bugzilla | |
parent | Bumped version to 4.4.9 (diff) | |
download | bugzilla-4b2ce2725e9a4525e273fb1b08243aad74770a3d.tar.gz bugzilla-4b2ce2725e9a4525e273fb1b08243aad74770a3d.tar.bz2 bugzilla-4b2ce2725e9a4525e273fb1b08243aad74770a3d.zip |
Vanilla 4.4.9 to 5.0 without history due to massive merge conflicts
Diffstat (limited to 'Bugzilla')
150 files changed, 12791 insertions, 1824 deletions
diff --git a/Bugzilla/Attachment.pm b/Bugzilla/Attachment.pm index cd8316a91..932fb6b17 100644 --- a/Bugzilla/Attachment.pm +++ b/Bugzilla/Attachment.pm @@ -5,10 +5,12 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Attachment; +use 5.10.1; +use strict; +use warnings; + =head1 NAME Bugzilla::Attachment - Bugzilla attachment class. @@ -44,8 +46,9 @@ use Bugzilla::Hook; use File::Copy; use List::Util qw(max); +use Storable qw(dclone); -use base qw(Bugzilla::Object); +use parent qw(Bugzilla::Object); ############################### #### Initialization #### @@ -58,22 +61,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', @@ -99,7 +99,8 @@ use constant VALIDATORS => { }; use constant VALIDATOR_DEPENDENCIES => { - mimetype => ['ispatch'], + content_type => ['ispatch'], + mimetype => ['ispatch'], }; use constant UPDATE_VALIDATORS => { @@ -125,8 +126,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 @@ -140,11 +140,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 @@ -158,8 +155,7 @@ user-provided text describing the attachment =cut sub description { - my $self = shift; - return $self->{description}; + return $_[0]->{description}; } =over @@ -173,8 +169,7 @@ the attachment's MIME media type =cut sub contenttype { - my $self = shift; - return $self->{mimetype}; + return $_[0]->{mimetype}; } =over @@ -188,10 +183,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 @@ -205,8 +198,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 @@ -220,8 +212,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 @@ -235,8 +226,7 @@ the name of the file the attacher attached =cut sub filename { - my $self = shift; - return $self->{filename}; + return $_[0]->{filename}; } =over @@ -250,8 +240,7 @@ whether or not the attachment is a patch =cut sub ispatch { - my $self = shift; - return $self->{ispatch}; + return $_[0]->{ispatch}; } =over @@ -265,8 +254,7 @@ whether or not the attachment is obsolete =cut sub isobsolete { - my $self = shift; - return $self->{isobsolete}; + return $_[0]->{isobsolete}; } =over @@ -280,8 +268,7 @@ whether or not the attachment is private =cut sub isprivate { - my $self = shift; - return $self->{isprivate}; + return $_[0]->{isprivate}; } =over @@ -298,8 +285,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. @@ -373,7 +359,7 @@ the length (in bytes) 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}; @@ -416,11 +402,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 @@ -443,8 +426,7 @@ sub flag_types { component_id => $self->bug->component_id, attach_id => $self->id }; - $self->{flag_types} = Bugzilla::Flag->_flag_types($vars); - return $self->{flag_types}; + return $self->{flag_types} = Bugzilla::Flag->_flag_types($vars); } ############################### @@ -674,23 +656,27 @@ sub get_attachments_by_bug { my $attachments = Bugzilla::Attachment->new_from_list($attach_ids); $_->{bug} = $bug foreach @$attachments; - # To avoid $attachment->flags to run SQL queries itself for each - # attachment listed here, we collect all the data at once and - # populate $attachment->{flags} ourselves. - # We also load all attachers at once for the same reason. + # To avoid $attachment->flags and $attachment->flag_types running SQL queries + # themselves for each attachment listed here, we collect all the data at once and + # populate $attachment->{flag_types} ourselves. We also load all attachers and + # datasizes at once for the same reason. if ($vars->{preload}) { - # Preload flags. - $_->{flags} = [] foreach @$attachments; - my %att = map { $_->id => $_ } @$attachments; - - my $flags = Bugzilla::Flag->match({ bug_id => $bug->id, - target_type => 'attachment' }); - - # Exclude flags for private attachments you cannot see. - @$flags = grep {exists $att{$_->attach_id}} @$flags; + # Preload flag types and flags + my $vars = { target_type => 'attachment', + product_id => $bug->product_id, + component_id => $bug->component_id, + attach_id => $attach_ids }; + my $flag_types = Bugzilla::Flag->_flag_types($vars); - push(@{$att{$_->attach_id}->{flags}}, $_) foreach @$flags; - $attachments = [sort {$a->id <=> $b->id} values %att]; + foreach my $attachment (@$attachments) { + $attachment->{flag_types} = []; + my $new_types = dclone($flag_types); + foreach my $new_type (@$new_types) { + $new_type->{flags} = [ grep($_->attach_id == $attachment->id, + @{ $new_type->{flags} }) ]; + push(@{ $attachment->{flag_types} }, $new_type); + } + } # Preload attachers. my %user_ids = map { $_->{submitter_id} => 1 } @$attachments; @@ -699,34 +685,44 @@ sub get_attachments_by_bug { 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; } =pod -=item C<validate_can_edit($attachment, $product_id)> +=item C<validate_can_edit> Description: validates if the user is allowed to view and edit the attachment. Only the submitter or someone with editbugs privs can edit it. Only the submitter and users in the insider group can view private attachments. -Params: $attachment - the attachment object being edited. - $product_id - the product ID the attachment belongs to. +Params: none Returns: 1 on success, 0 otherwise. =cut sub validate_can_edit { - my ($attachment, $product_id) = @_; + my $attachment = shift; my $user = Bugzilla->user; # The submitter can edit their attachments. return ($attachment->attacher->id == $user->id || ((!$attachment->isprivate || $user->is_insider) - && $user->in_group('editbugs', $product_id))) ? 1 : 0; + && $user->in_group('editbugs', $attachment->bug->product_id))) ? 1 : 0; } =item C<validate_obsolete($bug, $attach_ids)> @@ -734,7 +730,7 @@ sub validate_can_edit { Description: validates if attachments the user wants to mark as obsolete really belong to the given bug and are not already obsolete. Moreover, a user cannot mark an attachment as obsolete if - he cannot view it (due to restrictions on it). + they cannot view it (due to restrictions on it). Params: $bug - The bug object obsolete attachments should belong to. $attach_ids - The list of attachments to mark as obsolete. @@ -763,7 +759,7 @@ sub validate_obsolete { || ThrowUserError('invalid_attach_id', $vars); # Check that the user can view and edit this attachment. - $attachment->validate_can_edit($bug->product_id) + $attachment->validate_can_edit || ThrowUserError('illegal_attachment_edit', { attach_id => $attachment->id }); if ($attachment->bug_id != $bug->bug_id) { @@ -904,10 +900,14 @@ 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; + # because we updated the attachments table after SUPER::update(), we + # need to ensure the cache is flushed. + Bugzilla->memcached->clear({ table => 'attachments', id => $self->id }); } return $changes; @@ -932,7 +932,10 @@ sub remove_from_db { my $dbh = Bugzilla->dbh; $dbh->bz_start_transaction(); - $dbh->do('DELETE FROM flags WHERE attach_id = ?', undef, $self->id); + my $flag_ids = $dbh->selectcol_arrayref( + 'SELECT id FROM flags WHERE attach_id = ?', undef, $self->id); + $dbh->do('DELETE FROM flags WHERE ' . $dbh->sql_in('id', $flag_ids)) + if @$flag_ids; $dbh->do('DELETE FROM attach_data WHERE id = ?', undef, $self->id); $dbh->do('UPDATE attachments SET mimetype = ?, ispatch = ?, isobsolete = ? WHERE attach_id = ?', undef, ('text/plain', 0, 1, $self->id)); @@ -942,6 +945,13 @@ sub remove_from_db { if (-e $filename) { unlink $filename or warn "Couldn't unlink $filename: $!"; } + + # As we don't call SUPER->remove_from_db we need to manually clear + # memcached here. + Bugzilla->memcached->clear({ table => 'attachments', id => $self->id }); + foreach my $flag_id (@$flag_ids) { + Bugzilla->memcached->clear({ table => 'flags', id => $flag_id }); + } } ############################### @@ -985,3 +995,29 @@ sub get_content_type { 1; + +=head1 B<Methods in need of POD> + +=over + +=item set_filename + +=item set_is_obsolete + +=item DB_COLUMNS + +=item set_is_private + +=item set_content_type + +=item set_description + +=item get_content_type + +=item set_flags + +=item set_is_patch + +=item update + +=back diff --git a/Bugzilla/Attachment/PatchReader.pm b/Bugzilla/Attachment/PatchReader.pm index e75a660f2..d0e221220 100644 --- a/Bugzilla/Attachment/PatchReader.pm +++ b/Bugzilla/Attachment/PatchReader.pm @@ -5,29 +5,38 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. +package Bugzilla::Attachment::PatchReader; + +use 5.10.1; use strict; +use warnings; -package Bugzilla::Attachment::PatchReader; +use Config; +use IO::Select; +use IPC::Open3; +use Symbol 'gensym'; use Bugzilla::Error; use Bugzilla::Attachment; use Bugzilla::Util; +use constant PERLIO_IS_ENABLED => $Config{useperlio}; + sub process_diff { - my ($attachment, $format, $context) = @_; + my ($attachment, $format) = @_; my $dbh = Bugzilla->dbh; my $cgi = Bugzilla->cgi; my $lc = Bugzilla->localconfig; my $vars = {}; - my ($reader, $last_reader) = setup_patch_readers(undef, $context); + require PatchReader::Raw; + my $reader = new PatchReader::Raw; if ($format eq 'raw') { require PatchReader::DiffPrinter::raw; - $last_reader->sends_data_to(new PatchReader::DiffPrinter::raw()); + $reader->sends_data_to(new PatchReader::DiffPrinter::raw()); # Actually print out the patch. - print $cgi->header(-type => 'text/plain', - -expires => '+3M'); + print $cgi->header(-type => 'text/plain'); disable_utf8(); $reader->iterate_string('Attachment ' . $attachment->id, $attachment->data); } @@ -63,7 +72,7 @@ sub process_diff { $vars->{'description'} = $attachment->description; $vars->{'other_patches'} = \@other_patches; - setup_template_patch_reader($last_reader, $format, $context, $vars); + setup_template_patch_reader($reader, $vars); # The patch is going to be displayed in a HTML page and if the utf8 # param is enabled, we have to encode attachment data as utf8. if (Bugzilla->params->{'utf8'}) { @@ -75,11 +84,13 @@ sub process_diff { } sub process_interdiff { - my ($old_attachment, $new_attachment, $format, $context) = @_; + my ($old_attachment, $new_attachment, $format) = @_; my $cgi = Bugzilla->cgi; my $lc = Bugzilla->localconfig; my $vars = {}; + require PatchReader::Raw; + # Encode attachment data as utf8 if it's going to be displayed in a HTML # page using the UTF-8 encoding. if ($format ne 'raw' && Bugzilla->params->{'utf8'}) { @@ -98,23 +109,87 @@ 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 ($reader, $last_reader) = setup_patch_readers("", $context); + local $ENV{'PATH'} = $lc->{diffpath}; + + # Open the interdiff pipe, reading from both STDOUT and STDERR + # To avoid deadlocks, we have to read the entire output from all handles + my ($stdout, $stderr) = ('', ''); + my ($pid, $interdiff_stdout, $interdiff_stderr, $use_select); + 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] + ); + $use_select = !PERLIO_IS_ENABLED; + } else { + $interdiff_stderr = gensym; + $pid = open3(gensym, $interdiff_stdout, $interdiff_stderr, + $lc->{interdiffbin}, $old_filename, $new_filename); + $use_select = 1; + } + + if ($format ne 'raw' && Bugzilla->params->{'utf8'}) { + binmode $interdiff_stdout, ':utf8'; + binmode $interdiff_stderr, ':utf8'; + } else { + binmode $interdiff_stdout; + binmode $interdiff_stderr; + } + + if ($use_select) { + my $select = IO::Select->new(); + $select->add($interdiff_stdout, $interdiff_stderr); + while (my @handles = $select->can_read) { + foreach my $handle (@handles) { + my $line = <$handle>; + if (!defined $line) { + $select->remove($handle); + next; + } + if ($handle == $interdiff_stdout) { + $stdout .= $line; + } else { + $stderr .= $line; + } + } + } + waitpid($pid, 0) if $pid; + + } else { + local $/ = undef; + $stdout = <$interdiff_stdout>; + $stdout //= ''; + $stderr = <$interdiff_stderr>; + $stderr //= ''; + } + + close($interdiff_stdout), + close($interdiff_stderr); + + # Tidy up + unlink($old_filename) or warn "Could not unlink $old_filename: $!"; + unlink($new_filename) or warn "Could not unlink $new_filename: $!"; + + # Any output on STDERR means interdiff failed to full process the patches. + # Interdiff's error messages are generic and not useful to end users, so we + # show a generic failure message. + if ($stderr) { + warn($stderr); + $warning = 'interdiff3'; + } + + my $reader = new PatchReader::Raw; if ($format eq 'raw') { require PatchReader::DiffPrinter::raw; - $last_reader->sends_data_to(new PatchReader::DiffPrinter::raw()); + $reader->sends_data_to(new PatchReader::DiffPrinter::raw()); # Actually print out the patch. - print $cgi->header(-type => 'text/plain', - -expires => '+3M'); + print $cgi->header(-type => 'text/plain'); disable_utf8(); } else { - # In case the HTML page is displayed with the UTF-8 encoding. - binmode $interdiff_fh, ':utf8' if Bugzilla->params->{'utf8'}; - $vars->{'warning'} = $warning if $warning; $vars->{'bugid'} = $new_attachment->bug_id; $vars->{'oldid'} = $old_attachment->id; @@ -122,16 +197,10 @@ sub process_interdiff { $vars->{'newid'} = $new_attachment->id; $vars->{'new_desc'} = $new_attachment->description; - setup_template_patch_reader($last_reader, $format, $context, $vars); + setup_template_patch_reader($reader, $vars); } - $reader->iterate_fh($interdiff_fh, 'interdiff #' . $old_attachment->id . - ' #' . $new_attachment->id); - close $interdiff_fh; - $ENV{'PATH'} = ''; - - # Delete temporary files. - unlink($old_filename) or warn "Could not unlink $old_filename: $!"; - unlink($new_filename) or warn "Could not unlink $new_filename: $!"; + $reader->iterate_string('interdiff #' . $old_attachment->id . + ' #' . $new_attachment->id, $stdout); } ###################### @@ -143,7 +212,6 @@ sub get_unified_diff { # Bring in the modules we need. require PatchReader::Raw; - require PatchReader::FixPatchRoot; require PatchReader::DiffPrinter::raw; require PatchReader::PatchInfoGrabber; require File::Temp; @@ -155,14 +223,6 @@ sub get_unified_diff { my $reader = new 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'}); - $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(); $last_reader->sends_data_to($patch_info_grabber); @@ -209,46 +269,8 @@ sub warn_if_interdiff_might_fail { return undef; } -sub setup_patch_readers { - my ($diff_root, $context) = @_; - - # Parameters: - # format=raw|html - # context=patch|file|0-n - # collapsed=0|1 - # headers=0|1 - - # Define the patch readers. - # The reader that reads the patch in (whatever its format). - require PatchReader::Raw; - my $reader = new 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'})); - $last_reader->sends_data_to->diff_root($diff_root) if defined($diff_root); - $last_reader = $last_reader->sends_data_to; - } - - # Add in cvs context if we have the necessary info to do it - if ($context ne 'patch' && Bugzilla->localconfig->{cvsbin} - && Bugzilla->params->{'cvsroot_get'}) - { - require 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'})); - $last_reader = $last_reader->sends_data_to; - } - - return ($reader, $last_reader); -} - sub setup_template_patch_reader { - my ($last_reader, $format, $context, $vars) = @_; + my ($last_reader, $vars) = @_; my $cgi = Bugzilla->cgi; my $template = Bugzilla->template; @@ -263,24 +285,33 @@ sub setup_template_patch_reader { } $vars->{'collapsed'} = $cgi->param('collapsed'); - $vars->{'context'} = $context; - $vars->{'do_context'} = Bugzilla->localconfig->{cvsbin} - && Bugzilla->params->{'cvsroot_get'} && !$vars->{'newid'}; # Print everything out. print $cgi->header(-type => 'text/html'); $last_reader->sends_data_to(new PatchReader::DiffPrinter::template($template, - "attachment/diff-header.$format.tmpl", - "attachment/diff-file.$format.tmpl", - "attachment/diff-footer.$format.tmpl", - { %{$vars}, - bonsai_url => Bugzilla->params->{'bonsai_url'}, - lxr_url => Bugzilla->params->{'lxr_url'}, - lxr_root => Bugzilla->params->{'lxr_root'}, - })); + 'attachment/diff-header.html.tmpl', + 'attachment/diff-file.html.tmpl', + 'attachment/diff-footer.html.tmpl', + $vars)); } 1; __END__ + +=head1 B<Methods in need of POD> + +=over + +=item get_unified_diff + +=item process_diff + +=item warn_if_interdiff_might_fail + +=item setup_template_patch_reader + +=item process_interdiff + +=back diff --git a/Bugzilla/Auth.pm b/Bugzilla/Auth.pm index 09a2c1da4..c830f0506 100644 --- a/Bugzilla/Auth.pm +++ b/Bugzilla/Auth.pm @@ -7,7 +7,10 @@ package Bugzilla::Auth; +use 5.10.1; use strict; +use warnings; + use fields qw( _info_getter _verifier @@ -29,7 +32,7 @@ sub new { my $self = fields::new($class); $params ||= {}; - $params->{Login} ||= Bugzilla->params->{'user_info_class'} . ',Cookie'; + $params->{Login} ||= Bugzilla->params->{'user_info_class'} . ',Cookie,APIKey'; $params->{Verify} ||= Bugzilla->params->{'user_verify_class'}; $self->{_info_getter} = new Bugzilla::Auth::Login::Stack($params->{Login}); @@ -43,7 +46,6 @@ sub new { sub login { my ($self, $type) = @_; - my $dbh = Bugzilla->dbh; # Get login info from the cookie, form, environment variables, etc. my $login_info = $self->{_info_getter}->get_login_info(); @@ -52,7 +54,7 @@ sub login { return $self->_handle_login_result($login_info, $type); } - # Now verify his username and password against the DB, LDAP, etc. + # Now verify their username and password against the DB, LDAP, etc. if ($self->{_info_getter}->{successful}->requires_verification) { $login_info = $self->{_verifier}->check_credentials($login_info); if ($login_info->{failure}) { @@ -177,7 +179,7 @@ sub _handle_login_result { elsif ($fail_code == AUTH_LOGINFAILED or $fail_code == AUTH_NO_SUCH_USER) { my $remaining_attempts = MAX_LOGIN_ATTEMPTS - ($result->{failure_count} || 0); - ThrowUserError("invalid_username_or_password", + ThrowUserError("invalid_login_or_password", { remaining => $remaining_attempts }); } # The account may be disabled @@ -296,7 +298,7 @@ An incorrect username or password was given. The hashref may also contain a C<failure_count> element, which specifies how many times the account has failed to log in within the lockout period (see L</AUTH_LOCKOUT>). This is used to warn the user when -he is getting close to being locked out. +they are getting close to being locked out. =head2 C<AUTH_NO_SUCH_USER> diff --git a/Bugzilla/Auth/Login.pm b/Bugzilla/Auth/Login.pm index 290cb42ff..a5f089777 100644 --- a/Bugzilla/Auth/Login.pm +++ b/Bugzilla/Auth/Login.pm @@ -7,7 +7,10 @@ package Bugzilla::Auth::Login; +use 5.10.1; use strict; +use warnings; + use fields qw(); # Determines whether or not a user can logout. It's really a subroutine, diff --git a/Bugzilla/Auth/Login/APIKey.pm b/Bugzilla/Auth/Login/APIKey.pm new file mode 100644 index 000000000..63e35578a --- /dev/null +++ b/Bugzilla/Auth/Login/APIKey.pm @@ -0,0 +1,53 @@ +# 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::Auth::Login::APIKey; + +use 5.10.1; +use strict; +use warnings; + +use base qw(Bugzilla::Auth::Login); + +use Bugzilla::Constants; +use Bugzilla::User::APIKey; +use Bugzilla::Util; +use Bugzilla::Error; + +use constant requires_persistence => 0; +use constant requires_verification => 0; +use constant can_login => 0; +use constant can_logout => 0; + +# This method is only available to web services. An API key can never +# be used to authenticate a Web request. +sub get_login_info { + my ($self) = @_; + my $params = Bugzilla->input_params; + my ($user_id, $login_cookie); + + my $api_key_text = trim(delete $params->{'Bugzilla_api_key'}); + if (!i_am_webservice() || !$api_key_text) { + return { failure => AUTH_NODATA }; + } + + my $api_key = Bugzilla::User::APIKey->new({ name => $api_key_text }); + + if (!$api_key or $api_key->api_key ne $api_key_text) { + # The second part checks the correct capitalisation. Silly MySQL + ThrowUserError("api_key_not_valid"); + } + elsif ($api_key->revoked) { + ThrowUserError('api_key_revoked'); + } + + $api_key->update_last_used(); + + return { user_id => $api_key->user_id }; +} + +1; diff --git a/Bugzilla/Auth/Login/CGI.pm b/Bugzilla/Auth/Login/CGI.pm index f29e8c9c1..6003d62a5 100644 --- a/Bugzilla/Auth/Login/CGI.pm +++ b/Bugzilla/Auth/Login/CGI.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Login::CGI; + +use 5.10.1; use strict; -use base qw(Bugzilla::Auth::Login); +use warnings; + +use parent qw(Bugzilla::Auth::Login); use constant user_can_create_account => 1; use Bugzilla::Constants; diff --git a/Bugzilla/Auth/Login/Cookie.pm b/Bugzilla/Auth/Login/Cookie.pm index b20357307..c09f08d24 100644 --- a/Bugzilla/Auth/Login/Cookie.pm +++ b/Bugzilla/Auth/Login/Cookie.pm @@ -7,12 +7,16 @@ package Bugzilla::Auth::Login::Cookie; +use 5.10.1; use strict; +use warnings; use base qw(Bugzilla::Auth::Login); use fields qw(_login_token); use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Token; use Bugzilla::Util; use List::Util qw(first); @@ -47,6 +51,20 @@ sub get_login_info { @{$cgi->{'Bugzilla_cookie_list'}}; $user_id = $cookie->value if $cookie; } + + # If the call is for a web service, and an api token is provided, check + # it is valid. + if (i_am_webservice() && Bugzilla->input_params->{Bugzilla_api_token}) { + my $api_token = Bugzilla->input_params->{Bugzilla_api_token}; + my ($token_user_id, undef, undef, $token_type) + = Bugzilla::Token::GetTokenData($api_token); + if (!defined $token_type + || $token_type ne 'api_token' + || $user_id != $token_user_id) + { + ThrowUserError('auth_invalid_token', { token => $api_token }); + } + } } # If no cookies were provided, we also look for a login token @@ -73,7 +91,9 @@ sub get_login_info { AND (ipaddr = ? OR ipaddr IS NULL)', undef, ($login_cookie, $user_id, $ip_addr)); - # If the cookie is valid, return a valid username. + # If the cookie or token is valid, return a valid username. + # If they were not valid and we are using a webservice, then + # throw an error notifying the client. if (defined $db_cookie && $login_cookie eq $db_cookie) { # If we logged in successfully, then update the lastused # time on the login cookie @@ -81,12 +101,16 @@ 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 }; } @@ -97,9 +121,7 @@ sub login_token { return $self->{'_login_token'} if exists $self->{'_login_token'}; - if ($usage_mode ne USAGE_MODE_XMLRPC - && $usage_mode ne USAGE_MODE_JSON) - { + if (!i_am_webservice()) { return $self->{'_login_token'} = undef; } diff --git a/Bugzilla/Auth/Login/Env.pm b/Bugzilla/Auth/Login/Env.pm index 393ac600d..653df2bb3 100644 --- a/Bugzilla/Auth/Login/Env.pm +++ b/Bugzilla/Auth/Login/Env.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Login::Env; + +use 5.10.1; use strict; -use base qw(Bugzilla::Auth::Login); +use warnings; + +use parent qw(Bugzilla::Auth::Login); use Bugzilla::Constants; use Bugzilla::Error; @@ -21,7 +25,6 @@ use constant extern_id_used => 1; sub get_login_info { my ($self) = @_; - my $dbh = Bugzilla->dbh; my $env_id = $ENV{Bugzilla->params->{"auth_env_id"}} || ''; my $env_email = $ENV{Bugzilla->params->{"auth_env_email"}} || ''; diff --git a/Bugzilla/Auth/Login/Stack.pm b/Bugzilla/Auth/Login/Stack.pm index 17a5855b6..dc35998e4 100644 --- a/Bugzilla/Auth/Login/Stack.pm +++ b/Bugzilla/Auth/Login/Stack.pm @@ -6,7 +6,11 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Login::Stack; + +use 5.10.1; use strict; +use warnings; + use base qw(Bugzilla::Auth::Login); use fields qw( _stack diff --git a/Bugzilla/Auth/Persist/Cookie.pm b/Bugzilla/Auth/Persist/Cookie.pm index b0aeb4f0f..2d1291f3b 100644 --- a/Bugzilla/Auth/Persist/Cookie.pm +++ b/Bugzilla/Auth/Persist/Cookie.pm @@ -6,7 +6,11 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Persist::Cookie; + +use 5.10.1; use strict; +use warnings; + use fields qw(); use Bugzilla::Constants; @@ -108,7 +112,7 @@ sub logout { if ($cookie) { push(@login_cookies, $cookie->value); } - elsif ($cookie = $cgi->cookie("Bugzilla_logincookie")) { + elsif ($cookie = $cgi->cookie('Bugzilla_logincookie')) { push(@login_cookies, $cookie); } @@ -147,6 +151,7 @@ sub logout { if ($type != LOGOUT_KEEP_CURRENT) { clear_browser_cookies(); } + } sub clear_browser_cookies { diff --git a/Bugzilla/Auth/Verify.pm b/Bugzilla/Auth/Verify.pm index ae256dd8c..e44fb06ae 100644 --- a/Bugzilla/Auth/Verify.pm +++ b/Bugzilla/Auth/Verify.pm @@ -7,7 +7,10 @@ package Bugzilla::Auth::Verify; +use 5.10.1; use strict; +use warnings; + use fields qw(); use Bugzilla::Constants; @@ -88,6 +91,7 @@ sub create_or_update_user { if ($extern_id && $username_user_id && !$extern_user_id) { $dbh->do('UPDATE profiles SET extern_id = ? WHERE userid = ?', undef, $extern_id, $username_user_id); + Bugzilla->memcached->clear({ table => 'profiles', id => $username_user_id }); } # Finally, at this point, one of these will give us a valid user id. @@ -233,3 +237,11 @@ edit the extern_id for all users. The default value is C<false>. =back + +=head1 B<Methods in need of POD> + +=over + +=item can_change_password + +=back diff --git a/Bugzilla/Auth/Verify/DB.pm b/Bugzilla/Auth/Verify/DB.pm index 99dc48ddc..28a9310c9 100644 --- a/Bugzilla/Auth/Verify/DB.pm +++ b/Bugzilla/Auth/Verify/DB.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Verify::DB; + +use 5.10.1; use strict; -use base qw(Bugzilla::Auth::Verify); +use warnings; + +use parent qw(Bugzilla::Auth::Verify); use Bugzilla::Constants; use Bugzilla::Token; @@ -52,10 +56,19 @@ sub check_credentials { }; } - # Force the user to type a longer password if it's too short. - if (length($password) < USER_PASSWORD_MIN_LENGTH) { - return { failure => AUTH_ERROR, user_error => 'password_current_too_short', - details => { locked_user => $user } }; + # Force the user to change their password if it does not meet the current + # criteria. This should usually only happen if the criteria has changed. + if (Bugzilla->usage_mode == USAGE_MODE_BROWSER && + Bugzilla->params->{password_check_on_login}) + { + my $check = validate_password_check($password); + if ($check) { + return { + failure => AUTH_ERROR, + user_error => $check, + details => { locked_user => $user } + } + } } # The user's credentials are okay, so delete any outstanding @@ -63,11 +76,22 @@ sub check_credentials { Bugzilla::Token::DeletePasswordTokens($user->id, "user_logged_in"); $user->clear_login_failures(); + my $update_password = 0; + # If their old password was using crypt() or some different hash # than we're using now, convert the stored password to using # whatever hashing system we're using now. my $current_algorithm = PASSWORD_DIGEST_ALGORITHM; - if ($real_password_crypted !~ /{\Q$current_algorithm\E}$/) { + $update_password = 1 if ($real_password_crypted !~ /{\Q$current_algorithm\E}$/); + + # If their old password was using a different length salt than what + # we're using now, update the password to use the new salt length. + if ($real_password_crypted =~ /^([^,]+),/) { + $update_password = 1 if (length($1) != PASSWORD_SALT_LENGTH); + } + + # If needed, update the user's password. + if ($update_password) { # We can't call $user->set_password because we don't want the password # complexity rules to apply here. $user->{cryptpassword} = bz_crypt($password); @@ -83,6 +107,7 @@ sub change_password { my $cryptpassword = bz_crypt($password); $dbh->do("UPDATE profiles SET cryptpassword = ? WHERE userid = ?", undef, $cryptpassword, $user->id); + Bugzilla->memcached->clear({ table => 'profiles', id => $user->id }); } 1; diff --git a/Bugzilla/Auth/Verify/LDAP.pm b/Bugzilla/Auth/Verify/LDAP.pm index 5704c5848..e37f55793 100644 --- a/Bugzilla/Auth/Verify/LDAP.pm +++ b/Bugzilla/Auth/Verify/LDAP.pm @@ -6,7 +6,11 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Verify::LDAP; + +use 5.10.1; use strict; +use warnings; + use base qw(Bugzilla::Auth::Verify); use fields qw( ldap @@ -149,7 +153,7 @@ sub _bind_ldap_for_search { # We can't just do this in new(), because we're not allowed to throw any # error from anywhere under Bugzilla::Auth::new -- otherwise we # could create a situation where the admin couldn't get to editparams -# to fix his mistake. (Because Bugzilla->login always calls +# to fix their mistake. (Because Bugzilla->login always calls # Bugzilla::Auth->new, and almost every page calls Bugzilla->login.) sub ldap { my ($self) = @_; diff --git a/Bugzilla/Auth/Verify/RADIUS.pm b/Bugzilla/Auth/Verify/RADIUS.pm index d6c4db8e8..283d9b466 100644 --- a/Bugzilla/Auth/Verify/RADIUS.pm +++ b/Bugzilla/Auth/Verify/RADIUS.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Verify::RADIUS; + +use 5.10.1; use strict; -use base qw(Bugzilla::Auth::Verify); +use warnings; + +use parent qw(Bugzilla::Auth::Verify); use Bugzilla::Constants; use Bugzilla::Error; diff --git a/Bugzilla/Auth/Verify/Stack.pm b/Bugzilla/Auth/Verify/Stack.pm index 0930d57ed..3e5db3cec 100644 --- a/Bugzilla/Auth/Verify/Stack.pm +++ b/Bugzilla/Auth/Verify/Stack.pm @@ -6,7 +6,11 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Auth::Verify::Stack; + +use 5.10.1; use strict; +use warnings; + use base qw(Bugzilla::Auth::Verify); use fields qw( _stack diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm index b390c12d4..bfc2fe0d9 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -7,7 +7,9 @@ package Bugzilla::Bug; +use 5.10.1; use strict; +use warnings; use Bugzilla::Attachment; use Bugzilla::Constants; @@ -27,15 +29,14 @@ use Bugzilla::Group; use Bugzilla::Status; use Bugzilla::Comment; use Bugzilla::BugUrl; +use Bugzilla::BugUserLastVisit; use List::MoreUtils qw(firstidx uniq part); use List::Util qw(min max first); use Storable qw(dclone); -use URI; -use URI::QueryParam; use Scalar::Util qw(blessed); -use base qw(Bugzilla::Object Exporter); +use parent qw(Bugzilla::Object Exporter); @Bugzilla::Bug::EXPORT = qw( bug_alias_to_id LogActivityEntry @@ -48,11 +49,13 @@ use base qw(Bugzilla::Object Exporter); use constant DB_TABLE => 'bugs'; use constant ID_FIELD => 'bug_id'; -use constant NAME_FIELD => 'alias'; +use constant NAME_FIELD => 'bug_id'; use constant LIST_ORDER => ID_FIELD; # Bugs have their own auditing table, bugs_activity. use constant AUDIT_CREATES => 0; use constant AUDIT_UPDATES => 0; +# This will be enabled later +use constant USE_MEMCACHED => 0; # This is a sub because it needs to call other subroutines. sub DB_COLUMNS { @@ -62,7 +65,6 @@ sub DB_COLUMNS { my @custom_names = map {$_->name} @custom; my @columns = (qw( - alias assigned_to bug_file_loc bug_id @@ -70,6 +72,7 @@ sub DB_COLUMNS { bug_status cclist_accessible component_id + creation_ts delta_ts estimated_time everconfirmed @@ -88,7 +91,6 @@ 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); @@ -145,6 +147,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; } @@ -154,6 +159,9 @@ sub VALIDATORS { elsif ($field->type == FIELD_TYPE_TEXTAREA) { $validator = \&_check_textarea_field; } + elsif ($field->type == FIELD_TYPE_INTEGER) { + $validator = \&_check_integer_field; + } else { $validator = \&_check_default_field; } @@ -199,7 +207,6 @@ sub UPDATE_COLUMNS { Bugzilla->active_custom_fields; my @custom_names = map {$_->name} @custom; my @columns = qw( - alias assigned_to bug_file_loc bug_severity @@ -232,7 +239,9 @@ use constant NUMERIC_COLUMNS => qw( ); sub DATE_COLUMNS { - my @fields = @{ Bugzilla->fields({ type => FIELD_TYPE_DATETIME }) }; + my @fields = (@{ Bugzilla->fields({ type => [FIELD_TYPE_DATETIME, + FIELD_TYPE_DATE] }) + }); return map { $_->name } @fields; } @@ -263,10 +272,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 => { @@ -294,21 +299,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; @@ -316,9 +311,22 @@ 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 !~ /^\d+$/) + || (ref($param) && $param->{id} !~ /^\d+$/)) + { if ($param) { - $param = { name => $param }; + my $alias = ref($param) ? $param->{id} : $param; + my $bug_id = bug_alias_to_id($alias); + if (! $bug_id) { + my $error_self = {}; + bless $error_self, $class; + $error_self->{'bug_id'} = $alias; + $error_self->{'error'} = 'InvalidBugId'; + return $error_self; + } + $param = { id => $bug_id, + cache => ref($param) ? $param->{cache} : 0 }; } else { # We got something that's not a number. @@ -352,6 +360,17 @@ sub new { return $self; } +sub initialize { + $_[0]->_create_cf_accessors(); +} + +sub object_cache_key { + my $class = shift; + my $key = $class->SUPER::object_cache_key(@_) + || return; + return $key . ',' . Bugzilla->user->id; +} + sub check { my $class = shift; my ($param, $field) = @_; @@ -486,8 +505,9 @@ sub preload { # to the more complex method. my @all_dep_ids; foreach my $bug (@$bugs) { - push(@all_dep_ids, @{ $bug->blocked }, @{ $bug->dependson }); - push(@all_dep_ids, @{ $bug->duplicate_ids }); + push @all_dep_ids, @{ $bug->blocked }, @{ $bug->dependson }; + push @all_dep_ids, @{ $bug->duplicate_ids }; + push @all_dep_ids, @{ $bug->_preload_referenced_bugs }; } @all_dep_ids = uniq @all_dep_ids; # If we don't do this, can_see_bug will do one call per bug in @@ -495,6 +515,60 @@ sub preload { $user->visible_bugs(\@all_dep_ids); } +# Helps load up bugs referenced in comments by retrieving them with a single +# query from the database and injecting bug objects into the object-cache. +sub _preload_referenced_bugs { + my $self = shift; + + # inject current duplicates into the object-cache first + foreach my $bug (@{ $self->duplicates }) { + $bug->object_cache_set() unless Bugzilla::Bug->object_cache_get($bug->id); + } + + # preload bugs from comments + my $referenced_bug_ids = _extract_bug_ids($self->comments); + my @ref_bug_ids = grep { !Bugzilla::Bug->object_cache_get($_) } @$referenced_bug_ids; + + # inject into object-cache + my $referenced_bugs = Bugzilla::Bug->new_from_list(\@ref_bug_ids); + $_->object_cache_set() foreach @$referenced_bugs; + + return $referenced_bug_ids +} + +# Extract bug IDs mentioned in comments. This is much faster than calling quoteUrls(). +sub _extract_bug_ids { + my $comments = shift; + my @bug_ids; + + my $params = Bugzilla->params; + my @urlbases = ($params->{'urlbase'}); + push(@urlbases, $params->{'sslbase'}) if $params->{'sslbase'}; + my $urlbase_re = '(?:' . join('|', map { qr/$_/ } @urlbases) . ')'; + my $bug_word = template_var('terms')->{bug}; + my $bugs_word = template_var('terms')->{bugs}; + + foreach my $comment (@$comments) { + if ($comment->type == CMT_HAS_DUPE || $comment->type == CMT_DUPE_OF) { + push @bug_ids, $comment->extra_data; + next; + } + my $s = $comment->already_wrapped ? qr/\s/ : qr/\h/; + my $text = $comment->body; + # Full bug links + push @bug_ids, $text =~ /\b$urlbase_re\Qshow_bug.cgi?id=\E(\d+)(?:\#c\d+)?/g; + # bug X + my $bug_re = qr/\Q$bug_word\E$s*\#?$s*(\d+)/i; + push @bug_ids, $text =~ /\b$bug_re/g; + # bugs X, Y, Z + my $bugs_re = qr/\Q$bugs_word\E$s*\#?$s*(\d+)(?:$s*,$s*\#?$s*(\d+))+/i; + push @bug_ids, $text =~ /\b$bugs_re/g; + # Old duplicate markers + push @bug_ids, $text =~ /(?<=^\*\*\*\ This\ bug\ has\ been\ marked\ as\ a\ duplicate\ of\ )(\d+)(?=\ \*\*\*\Z)/; + } + return [uniq @bug_ids]; +} + sub possible_duplicates { my ($class, $params) = @_; my $short_desc = $params->{summary}; @@ -635,12 +709,14 @@ sub create { # These are not a fields in the bugs table, so we don't pass them to # insert_create_data. + my $bug_aliases = delete $params->{alias}; my $cc_ids = delete $params->{cc}; my $groups = delete $params->{groups}; my $depends_on = delete $params->{dependson}; my $blocked = delete $params->{blocked}; my $keywords = delete $params->{keywords}; my $creation_comment = delete $params->{comment}; + my $see_also = delete $params->{see_also}; # We don't want the bug to appear in the system until it's correctly # protected by groups. @@ -704,6 +780,25 @@ sub create { } } + # Insert any see_also values + if ($see_also) { + my $see_also_array = $see_also; + if (!ref $see_also_array) { + $see_also = trim($see_also); + $see_also_array = [ split(/[\s,]+/, $see_also) ]; + } + foreach my $value (@$see_also_array) { + $bug->add_see_also($value); + } + foreach my $see_also (@{ $bug->see_also }) { + $see_also->insert_create_data($see_also); + } + foreach my $ref_bug (@{ $bug->{_update_ref_bugs} || [] }) { + $ref_bug->update(); + } + delete $bug->{_update_ref_bugs}; + } + # Comment #0 handling... # We now have a bug id so we can fill this out @@ -713,6 +808,13 @@ sub create { # but sometimes it's blank. Bugzilla::Comment->insert_create_data($creation_comment); + # Set up aliases + my $sth_aliases = $dbh->prepare('INSERT INTO bugs_aliases (alias, bug_id) VALUES (?, ?)'); + foreach my $alias (@$bug_aliases) { + trick_taint($alias); + $sth_aliases->execute($alias, $bug->bug_id); + } + Bugzilla::Hook::process('bug_end_of_create', { bug => $bug, timestamp => $timestamp, }); @@ -776,8 +878,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)'); @@ -831,7 +934,26 @@ sub update { my $added_names = join(', ', (map {$_->login} @$added_users)); $changes->{cc} = [$removed_names, $added_names]; } - + + # Aliases + my $old_aliases = $old_bug->alias; + my $new_aliases = $self->alias; + my ($removed_aliases, $added_aliases) = diff_arrays($old_aliases, $new_aliases); + + foreach my $alias (@$removed_aliases) { + $dbh->do('DELETE FROM bugs_aliases WHERE bug_id = ? AND alias = ?', + undef, $self->id, $alias); + } + foreach my $alias (@$added_aliases) { + trick_taint($alias); + $dbh->do('INSERT INTO bugs_aliases (bug_id, alias) VALUES (?,?)', + undef, $self->id, $alias); + } + # If any changes were found, record it in the activity log + if (scalar @$removed_aliases || scalar @$added_aliases) { + $changes->{alias} = [join(', ', @$removed_aliases), join(', ', @$added_aliases)]; + } + # Keywords my @old_kw_ids = map { $_->id } @{$old_bug->keyword_objects}; my @new_kw_ids = map { $_->id } @{$self->keyword_objects}; @@ -868,7 +990,7 @@ 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); @@ -879,7 +1001,7 @@ 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); @@ -921,7 +1043,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); } } @@ -932,7 +1054,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); } # Clear the cache of comments @@ -986,8 +1108,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. @@ -1001,7 +1123,7 @@ sub update { $update_dup->update(); } } - + $changes->{'dup_id'} = [$old_dup || undef, $cur_dup || undef]; } @@ -1018,6 +1140,37 @@ sub update { $self->{delta_ts} = $delta_ts; } + # Update last-visited + if ($user->is_involved_in_bug($self)) { + $self->update_user_last_visit($user, $delta_ts); + } + + # If a user is no longer involved, remove their last visit entry + my $last_visits = + Bugzilla::BugUserLastVisit->match({ bug_id => $self->id }); + foreach my $lv (@$last_visits) { + $lv->remove_from_db() unless $lv->user->is_involved_in_bug($self); + } + + # 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 @@ -1035,7 +1188,7 @@ 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}; return $changes; } @@ -1214,32 +1367,38 @@ sub _send_bugmail { ##################################################################### sub _check_alias { - my ($invocant, $alias) = @_; - $alias = trim($alias); - return undef if (!$alias); - - # Make sure the alias isn't too long. - if (length($alias) > 20) { - ThrowUserError("alias_too_long"); - } - # Make sure the alias isn't just a number. - if ($alias =~ /^\d+$/) { - ThrowUserError("alias_is_numeric", { alias => $alias }); - } - # Make sure the alias has no commas or spaces. - if ($alias =~ /[, ]/) { - ThrowUserError("alias_has_comma_or_space", { alias => $alias }); - } - # Make sure the alias is unique, or that it's already our alias. - my $other_bug = new Bugzilla::Bug($alias); - if (!$other_bug->{error} - && (!ref $invocant || $other_bug->id != $invocant->id)) - { - ThrowUserError("alias_in_use", { alias => $alias, - bug_id => $other_bug->id }); + my ($invocant, $aliases) = @_; + $aliases = ref $aliases ? $aliases : [split(/[\s,]+/, $aliases)]; + + # Remove empty aliases + @$aliases = grep { $_ } @$aliases; + + foreach my $alias (@$aliases) { + $alias = trim($alias); + + # Make sure the alias isn't too long. + if (length($alias) > 40) { + ThrowUserError("alias_too_long"); + } + # Make sure the alias isn't just a number. + if ($alias =~ /^\d+$/) { + ThrowUserError("alias_is_numeric", { alias => $alias }); + } + # Make sure the alias has no commas or spaces. + if ($alias =~ /[, ]/) { + ThrowUserError("alias_has_comma_or_space", { alias => $alias }); + } + # Make sure the alias is unique, or that it's already our alias. + my $other_bug = new Bugzilla::Bug($alias); + if (!$other_bug->{error} + && (!ref $invocant || $other_bug->id != $invocant->id)) + { + ThrowUserError("alias_in_use", { alias => $alias, + bug_id => $other_bug->id }); + } } - return $alias; + return $aliases; } sub _check_assigned_to { @@ -1591,9 +1750,9 @@ sub _check_dup_id { } # Should we add the reporter to the CC list of the new bug? - # If he can see the bug... + # If they can see the bug... if ($self->reporter->can_see_bug($dupe_of)) { - # We only add him if he's not the reporter of the other bug. + # We only add them if they're not the reporter of the other bug. $self->{_add_dup_cc} = 1 if $dupe_of_bug->reporter->id != $self->reporter->id; } @@ -1608,11 +1767,11 @@ sub _check_dup_id { $self->{_add_dup_cc} = $add_confirmed; } else { - # Note that here we don't check if he user is already the reporter - # of the dupe_of bug, since we already checked if he can *see* + # Note that here we don't check if the user is already the reporter + # of the dupe_of bug, since we already checked if they can *see* # the bug, above. People might have reporter_accessible turned # off, but cclist_accessible turned on, so they might want to - # add the reporter even though he's already the reporter of the + # add the reporter even though they're already the reporter of the # dup_of bug. my $vars = {}; my $template = Bugzilla->template; @@ -1680,13 +1839,7 @@ sub _check_keywords { $keywords_in = trim($keywords_in); $keyword_array = [split(/[\s,]+/, $keywords_in)]; } - - # On creation, only editbugs users can set keywords. - if (!ref $invocant) { - my $product = $params->{product}; - return [] if !Bugzilla->user->in_group('editbugs', $product->id); - } - + my %keywords; foreach my $keyword (@$keyword_array) { next unless $keyword; @@ -1761,7 +1914,7 @@ sub _check_reporter { } else { # On bug creation, the reporter is the logged in user - # (meaning that he must be logged in first!). + # (meaning that they must be logged in first!). Bugzilla->login(LOGIN_REQUIRED); $reporter = Bugzilla->user->id; } @@ -1992,8 +2145,13 @@ 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. @@ -2007,6 +2165,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' }); @@ -2082,6 +2244,27 @@ sub _check_textarea_field { return $text; } +sub _check_integer_field { + my ($invocant, $value, $field) = @_; + $value = defined($value) ? trim($value) : ''; + + if ($value eq '') { + return 0; + } + + my $orig_value = $value; + if (!detaint_signed($value)) { + ThrowUserError("number_not_integer", + {field => $field, num => $orig_value}); + } + elsif ($value > MAX_INT_32) { + ThrowUserError("number_too_large", + {field => $field, num => $orig_value, max_num => MAX_INT_32}); + } + + return $value; +} + sub _check_relationship_loop { # Generates a dependency tree for a given bug. Calls itself recursively # to generate sub-trees for the bug's dependencies. @@ -2161,8 +2344,8 @@ sub _set_global_validator { my $can = $self->check_can_change_field($field, $current, $value, \$privs); if (!$can) { if ($field eq 'assigned_to' || $field eq 'qa_contact') { - $value = user_id_to_login($value); - $current = user_id_to_login($current); + $value = Bugzilla::User->new($value)->login; + $current = Bugzilla::User->new($current)->login; } ThrowUserError('illegal_change', { field => $field, oldvalue => $current, @@ -2255,6 +2438,15 @@ sub set_all { work_time => $params->{'work_time'} }); } + if (exists $params->{alias} && $params->{alias}{set}) { + my ($removed_aliases, $added_aliases) = diff_arrays( + $self->alias, $params->{alias}{set}); + $params->{alias} = { + add => $added_aliases, + remove => $removed_aliases, + }; + } + my %normal_set_all; foreach my $name (keys %$params) { # These are handled separately below. @@ -2279,13 +2471,14 @@ sub set_all { } $self->_add_remove($params, 'cc'); + $self->_add_remove($params, 'alias'); # Theoretically you could move a product without ever specifying # a new assignee or qa_contact, or adding/removing any CCs. So, # 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; } @@ -2295,14 +2488,13 @@ sub _add_remove { my ($self, $params, $name) = @_; my @add = @{ $params->{$name}->{add} || [] }; my @remove = @{ $params->{$name}->{remove} || [] }; - $name =~ s/s$//; + $name =~ s/s$// if $name ne 'alias'; my $add_method = "add_$name"; my $remove_method = "remove_$name"; $self->$add_method($_) foreach @add; $self->$remove_method($_) foreach @remove; } -sub set_alias { $_[0]->set('alias', $_[1]); } sub set_assigned_to { my ($self, $value) = @_; $self->set('assigned_to', $value); @@ -2315,30 +2507,36 @@ 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) = @_; + my ($self, $comments, $isprivate) = @_; + $self->{comment_isprivate} ||= []; + my $is_insider = Bugzilla->user->is_insider; + + $comments = { $comments => $isprivate } unless ref $comments; + + foreach my $comment (@{$self->comments}) { + # Skip unmodified comment privacy. + next unless exists $comments->{$comment->id}; - # We also allow people to pass in a hash of comment ids to update. - if (ref $comment_id) { - while (my ($id, $is) = each %$comment_id) { - $self->set_comment_is_private($id, $is); + my $isprivate = delete $comments->{$comment->id} ? 1 : 0; + if ($isprivate != $comment->is_private) { + ThrowUserError('user_not_insider') unless $is_insider; + $comment->set_is_private($isprivate); + push @{$self->{comment_isprivate}}, $comment; } - return; } - my ($comment) = grep($comment_id == $_->id, @{ $self->comments }); - ThrowUserError('comment_invalid_isprivate', { id => $comment_id }) - if !$comment; + # If there are still entries in $comments, then they are illegal. + ThrowUserError('comment_invalid_isprivate', { id => join(', ', keys %$comments) }) + if scalar keys %$comments; - $isprivate = $isprivate ? 1 : 0; - if ($isprivate != $comment->is_private) { - ThrowUserError('user_not_insider') if !Bugzilla->user->is_insider; - $self->{comment_isprivate} ||= []; - $comment->set_is_private($isprivate); - push @{$self->{comment_isprivate}}, $comment; - } + # If no comment privacy has been modified, remove this key. + delete $self->{comment_isprivate} unless scalar @{$self->{comment_isprivate}}; } + sub set_component { my ($self, $name) = @_; my $old_comp = $self->component_obj; @@ -2718,6 +2916,32 @@ sub remove_cc { @$cc_users = grep { $_->id != $user->id } @$cc_users; } +sub add_alias { + my ($self, $alias) = @_; + return if !$alias; + my $aliases = $self->_check_alias($alias); + $alias = $aliases->[0]; + my @new_aliases; + my $found = 0; + foreach my $old_alias (@{ $self->alias }) { + if (lc($old_alias) eq lc($alias)) { + push(@new_aliases, $alias); + $found = 1; + } + else { + push(@new_aliases, $old_alias); + } + } + push(@new_aliases, $alias) if !$found; + $self->{alias} = \@new_aliases; +} + +sub remove_alias { + my ($self, $alias) = @_; + my $bug_aliases = $self->alias; + @$bug_aliases = grep { $_ ne $alias } @$bug_aliases; +} + # $bug->add_comment("comment", {isprivate => 1, work_time => 10.5, # type => CMT_NORMAL, extra_data => $data}); sub add_comment { @@ -3045,7 +3269,6 @@ sub tags { # These are accessors that don't need to access the database. # Keep them in alphabetical order. -sub alias { return $_[0]->{alias} } sub bug_file_loc { return $_[0]->{bug_file_loc} } sub bug_id { return $_[0]->{bug_id} } sub bug_severity { return $_[0]->{bug_severity} } @@ -3128,7 +3351,7 @@ sub _resolve_ultimate_dup_id { # If $dupes{$this_dup} is already set to 1, then a loop # already exists which does not involve this bug. # As the user is not responsible for this loop, do not - # prevent him from marking this bug as a duplicate. + # prevent them from marking this bug as a duplicate. return $last_dup if exists $dupes{$this_dup}; $dupes{$this_dup} = 1; $last_dup = $this_dup; @@ -3155,6 +3378,19 @@ sub actual_time { return $self->{'actual_time'}; } +sub alias { + my ($self) = @_; + return $self->{'alias'} if exists $self->{'alias'}; + return [] if $self->{'error'}; + + my $dbh = Bugzilla->dbh; + $self->{'alias'} = $dbh->selectcol_arrayref( + q{SELECT alias FROM bugs_aliases WHERE bug_id = ? ORDER BY alias}, + undef, $self->bug_id); + + return $self->{'alias'}; +} + sub any_flags_requesteeble { my ($self) = @_; return $self->{'any_flags_requesteeble'} @@ -3178,6 +3414,7 @@ sub attachments { $self->{'attachments'} = Bugzilla::Attachment->get_attachments_by_bug($self, {preload => 1}); + $_->object_cache_set() foreach @{ $self->{'attachments'} }; return $self->{'attachments'}; } @@ -3185,7 +3422,7 @@ 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'}); + $self->{'assigned_to_obj'} ||= new Bugzilla::User({ id => $self->{'assigned_to'}, cache => 1 }); return $self->{'assigned_to_obj'}; } @@ -3248,11 +3485,8 @@ sub cc_users { sub component { my ($self) = @_; - return $self->{component} if exists $self->{component}; return '' if $self->{error}; - ($self->{component}) = Bugzilla->dbh->selectrow_array( - 'SELECT name FROM components WHERE id = ?', - undef, $self->{component_id}); + $self->{component} //= $self->component_obj->name; return $self->{component}; } @@ -3261,27 +3495,22 @@ 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}; } sub classification_id { my ($self) = @_; - return $self->{classification_id} if exists $self->{classification_id}; return 0 if $self->{error}; - ($self->{classification_id}) = Bugzilla->dbh->selectrow_array( - 'SELECT classification_id FROM products WHERE id = ?', - undef, $self->{product_id}); + $self->{classification_id} //= $self->product_obj->classification_id; return $self->{classification_id}; } sub classification { my ($self) = @_; - return $self->{classification} if exists $self->{classification}; return '' if $self->{error}; - ($self->{classification}) = Bugzilla->dbh->selectrow_array( - 'SELECT name FROM classifications WHERE id = ?', - undef, $self->classification_id); + $self->{classification} //= $self->product_obj->classification->name; return $self->{classification}; } @@ -3362,7 +3591,10 @@ sub flags { sub isopened { my $self = shift; - return is_open_state($self->{bug_status}) ? 1 : 0; + unless (exists $self->{isopened}) { + $self->{isopened} = is_open_state($self->{bug_status}) ? 1 : 0; + } + return $self->{isopened}; } sub keywords { @@ -3391,11 +3623,21 @@ sub comments { if (!defined $self->{'comments'}) { $self->{'comments'} = Bugzilla::Comment->match({ bug_id => $self->id }); my $count = 0; + state $is_mysql = Bugzilla->dbh->isa('Bugzilla::DB::Mysql') ? 1 : 0; foreach my $comment (@{ $self->{'comments'} }) { $comment->{count} = $count++; $comment->{bug} = $self; + # XXX - hack for MySQL. Convert [U+....] back into its Unicode + # equivalent for characters above U+FFFF as MySQL older than 5.5.3 + # cannot store them, see Bugzilla::Comment::_check_thetext(). + if ($is_mysql) { + # Perl 5.13.8 and older complain about non-characters. + no warnings 'utf8'; + $comment->{thetext} =~ s/\x{FDD0}\[U\+((?:[1-9A-F]|10)[0-9A-F]{4})\]\x{FDD1}/chr(hex $1)/eg + } } - Bugzilla::Comment->preload($self->{'comments'}); + # Some bugs may have no comments when upgrading old installations. + Bugzilla::Comment->preload($self->{'comments'}) if $count; } my @comments = @{ $self->{'comments'} }; @@ -3457,11 +3699,8 @@ sub percentage_complete { sub product { my ($self) = @_; - return $self->{product} if exists $self->{product}; return '' if $self->{error}; - ($self->{product}) = Bugzilla->dbh->selectrow_array( - 'SELECT name FROM products WHERE id = ?', - undef, $self->{product_id}); + $self->{product} //= $self->product_obj->name; return $self->{product}; } @@ -3469,7 +3708,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}; } @@ -3479,7 +3719,7 @@ 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 { $self->{'qa_contact_obj'} = undef; } @@ -3490,7 +3730,7 @@ 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'}); + $self->{'reporter'} = new Bugzilla::User({ id => $self->{'reporter_id'}, cache => 1 }); return $self->{'reporter'}; } @@ -3732,6 +3972,11 @@ sub choices { my @resolutions = grep($_->name, @{ $resolution_field->legal_values }); $choices{'resolution'} = \@resolutions; + foreach my $key (keys %choices) { + my $value = $self->$key; + $choices{$key} = [grep { $_->is_active || $_->name eq $value } @{ $choices{$key} }]; + } + $self->{'choices'} = \%choices; return $self->{'choices'}; } @@ -3746,7 +3991,7 @@ sub bug_alias_to_id { my $dbh = Bugzilla->dbh; trick_taint($alias); return $dbh->selectrow_array( - "SELECT bug_id FROM bugs WHERE alias = ?", undef, $alias); + "SELECT bug_id FROM bugs_aliases WHERE alias = ?", undef, $alias); } ##################################################################### @@ -3770,9 +4015,7 @@ sub editable_bug_fields { # Ensure field exists before attempting to remove it. splice(@fields, $location, 1) if ($location > -1); } - # Sorted because the old @::log_columns variable, which this replaces, - # was sorted. - return sort(@fields); + return @fields; } # XXX - When Bug::update() will be implemented, we should make this routine @@ -3803,16 +4046,48 @@ sub EmitDependList { # Creates a lot of bug objects in the same order as the input array. sub _bugs_in_order { my ($self, $bug_ids) = @_; - my $bugs = $self->new_from_list($bug_ids); - my %bug_map = map { $_->id => $_ } @$bugs; - my @result = map { $bug_map{$_} } @$bug_ids; - return \@result; + return [] unless @$bug_ids; + + my %bug_map; + my $dbh = Bugzilla->dbh; + + # there's no need to load bugs from the database if they are already in the + # object-cache + my @missing_ids; + foreach my $bug_id (@$bug_ids) { + if (my $bug = Bugzilla::Bug->object_cache_get($bug_id)) { + $bug_map{$bug_id} = $bug; + } + else { + push @missing_ids, $bug_id; + } + } + if (@missing_ids) { + my $bugs = Bugzilla::Bug->new_from_list(\@missing_ids); + $bug_map{$_->id} = $_ foreach @$bugs; + } + + # Dependencies are often displayed using their aliases instead of their + # bug ID. Load them all at once. + my $rows = $dbh->selectall_arrayref( + 'SELECT bug_id, alias FROM bugs_aliases WHERE ' . + $dbh->sql_in('bug_id', $bug_ids) . ' ORDER BY alias'); + + foreach my $row (@$rows) { + my ($bug_id, $alias) = @$row; + $bug_map{$bug_id}->{alias} ||= []; + push @{ $bug_map{$bug_id}->{alias} }, $alias; + } + # Make sure all bugs have their alias attribute set. + $bug_map{$_}->{alias} ||= [] foreach @$bug_ids; + + return [ map { $bug_map{$_} } @$bug_ids ]; } # Get the activity of a bug, starting from $starttime (if given). # This routine assumes Bugzilla::Bug->check has been previously called. sub get_activity { - my ($self, $attach_id, $starttime) = @_; + my ($self, $attach_id, $starttime, $include_comment_tags) = @_; my $dbh = Bugzilla->dbh; my $user = Bugzilla->user; @@ -3824,7 +4099,7 @@ sub get_activity { if (defined $starttime) { trick_taint($starttime); push (@args, $starttime); - $datepart = "AND bugs_activity.bug_when > ?"; + $datepart = "AND bug_when > ?"; } my $attachpart = ""; @@ -3844,7 +4119,7 @@ sub get_activity { 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 @@ -3855,8 +4130,42 @@ sub get_activity { WHERE bugs_activity.bug_id = ? $datepart $attachpart - $suppwhere - ORDER BY bugs_activity.bug_when, bugs_activity.id"; + $suppwhere "; + + if (Bugzilla->params->{'comment_taggers_group'} + && $include_comment_tags + && !$attach_id) + { + # Only includes comment tag activity for comments the user is allowed to see. + $suppjoins = ""; + $suppwhere = ""; + if (!Bugzilla->user->is_insider) { + $suppjoins = "INNER JOIN longdescs + ON longdescs.comment_id = longdescs_tags_activity.comment_id"; + $suppwhere = "AND longdescs.isprivate = 0"; + } + + $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 + $suppjoins + WHERE longdescs_tags_activity.bug_id = ? + $datepart + $suppwhere + "; + push @args, $self->id; + push @args, $starttime if defined $starttime; + } + + $query .= "ORDER BY bug_when, comment_id"; my $list = $dbh->selectall_arrayref($query, undef, @args); @@ -3943,9 +4252,13 @@ sub get_activity { # Update the bugs_activity table to reflect changes made in bugs. sub LogActivityEntry { - my ($i, $col, $removed, $added, $whoid, $timestamp, $comment_id, + my ($bug_id, $field, $removed, $added, $user_id, $timestamp, $comment_id, $attach_id) = @_; - my $dbh = Bugzilla->dbh; + my $sth = Bugzilla->dbh->prepare_cached( + 'INSERT INTO bugs_activity + (bug_id, who, bug_when, fieldid, removed, added, comment_id, attach_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + # in the case of CCs, deps, and keywords, there's a possibility that someone # might try to add or remove a lot of them at once, which might take more # space than the activity table allows. We'll solve this by splitting it @@ -3968,14 +4281,26 @@ sub LogActivityEntry { } trick_taint($addstr); trick_taint($removestr); - my $fieldid = get_field_id($col); - $dbh->do( - "INSERT INTO bugs_activity - (bug_id, who, bug_when, fieldid, removed, added, comment_id, attach_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - undef, - ($i, $whoid, $timestamp, $fieldid, $removestr, $addstr, $comment_id, - $attach_id)); + my $fieldid = get_field_id($field); + $sth->execute($bug_id, $user_id, $timestamp, $fieldid, $removestr, + $addstr, $comment_id, $attach_id); + } +} + +# Update bug_user_last_visit table +sub update_user_last_visit { + my ($self, $user, $last_visit_ts) = @_; + my $lv = Bugzilla::BugUserLastVisit->match({ bug_id => $self->id, + user_id => $user->id })->[0]; + + if ($lv) { + $lv->set(last_visit_ts => $last_visit_ts); + $lv->update; + } + else { + Bugzilla::BugUserLastVisit->create({ bug_id => $self->id, + user_id => $user->id, + last_visit_ts => $last_visit_ts }); } } @@ -4059,7 +4384,7 @@ sub check_can_change_field { return 1; } - # If the user isn't allowed to change a field, we must tell him who can. + # If the user isn't allowed to change a field, we must tell them who can. # We store the required permission set into the $PrivilegesRequired # variable which gets passed to the error template. # @@ -4067,9 +4392,10 @@ sub check_can_change_field { # $PrivilegesRequired = PRIVILEGES_REQUIRED_REPORTER : the reporter, assignee or an empowered user; # $PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE : the assignee or an empowered user; # $PrivilegesRequired = PRIVILEGES_REQUIRED_EMPOWERED : an empowered user. - - # Only users in the time-tracking group can change time-tracking fields. - if ( grep($_ eq $field, TIMETRACKING_FIELDS) ) { + + # Only users in the time-tracking group can change time-tracking fields, + # including the deadline. + if (grep { $_ eq $field } (TIMETRACKING_FIELDS, 'deadline')) { if (!$user->is_timetracker) { $$PrivilegesRequired = PRIVILEGES_REQUIRED_EMPOWERED; return 0; @@ -4110,7 +4436,7 @@ sub check_can_change_field { # is not allowed to change. # The reporter may not: - # - reassign bugs, unless the bugs are assigned to him; + # - reassign bugs, unless the bugs are assigned to them; # in that case we will have already returned 1 above # when checking for the assignee of the bug. if ($field eq 'assigned_to') { @@ -4127,7 +4453,7 @@ sub check_can_change_field { $$PrivilegesRequired = PRIVILEGES_REQUIRED_ASSIGNEE; return 0; } - # - change the priority (unless he could have set it originally) + # - change the priority (unless they could have set it originally) if ($field eq 'priority' && !Bugzilla->params->{'letsubmitterchoosepriority'}) { @@ -4197,6 +4523,10 @@ sub ValidateDependencies { my $dbh = Bugzilla->dbh; my %deps; my %deptree; + my %sth; + $sth{dependson} = $dbh->prepare('SELECT dependson FROM dependencies WHERE blocked = ?'); + $sth{blocked} = $dbh->prepare('SELECT blocked FROM dependencies WHERE dependson = ?'); + foreach my $pair (["blocked", "dependson"], ["dependson", "blocked"]) { my ($me, $target) = @{$pair}; $deptree{$target} = []; @@ -4221,10 +4551,7 @@ sub ValidateDependencies { my @stack = @{$deps{$target}}; while (@stack) { my $i = shift @stack; - my $dep_list = - $dbh->selectcol_arrayref("SELECT $target - FROM dependencies - WHERE $me = ?", undef, $i); + my $dep_list = $dbh->selectcol_arrayref($sth{$target}, undef, $i); foreach my $t (@$dep_list) { # ignore any _current_ dependencies involving this bug, # as they will be overwritten with data from the form. @@ -4303,3 +4630,295 @@ sub _multi_select_accessor { } 1; + +__END__ +=head1 B<Methods> + +=over + +=item C<initialize> + +Ensures the accessors for custom fields are always created. + +=item C<add_alias($alias)> + +Adds an alias to the internal respresentation of the bug. You will need to +call L<update> to make the changes permanent. + +=item C<remove_alias($alias)> + +Removes an alias from the internal respresentation of the bug. You will need to +call L<update> to make the changes permanent. + +=item C<update_user_last_visit($user, $last_visit)> + +Creates or updates a L<Bugzilla::BugUserLastVisit> for this bug and the supplied +$user, the timestamp given as $last_visit. + +=back + +=head1 B<Methods in need of POD> + +=over + +=item remove_cc + +=item add_see_also + +=item choices + +=item keywords + +=item blocked + +=item qa_contact + +=item add_comment + +=item bug_severity + +=item dup_id + +=item set_priority + +=item any_flags_requesteeble + +=item set_bug_status + +=item estimated_time + +=item set_platform + +=item statuses_available + +=item set_custom_field + +=item remove_see_also + +=item remove_from_db + +=item product_obj + +=item reporter_accessible + +=item set_summary + +=item LogActivityEntry + +=item set_assigned_to + +=item add_group + +=item bug_file_loc + +=item DATE_COLUMNS + +=item set_component + +=item delta_ts + +=item set_resolution + +=item version + +=item deadline + +=item fields + +=item dependson + +=item check_can_change_field + +=item update + +=item set_op_sys + +=item object_cache_key + +=item bug_group + +=item comments + +=item map_fields + +=item assigned_to + +=item user + +=item ValidateDependencies + +=item short_desc + +=item duplicate_ids + +=item isopened + +=item remaining_time + +=item set_deadline + +=item preload + +=item groups_in + +=item clear_resolution + +=item set_estimated_time + +=item in_group + +=item status + +=item get_activity + +=item reporter + +=item rep_platform + +=item DB_COLUMNS + +=item flag_types + +=item bug_status + +=item attachments + +=item flags + +=item set_flags + +=item actual_time + +=item component + +=item UPDATE_COLUMNS + +=item set_cclist_accessible + +=item set_bug_ignored + +=item product + +=item VALIDATORS + +=item show_attachment_flags + +=item set_comment_is_private + +=item set_severity + +=item send_changes + +=item add_tag + +=item bug_id + +=item reset_qa_contact + +=item remove_group + +=item set_dup_id + +=item set_target_milestone + +=item cc_users + +=item everconfirmed + +=item check_is_visible + +=item check_for_edit + +=item match + +=item VALIDATOR_DEPENDENCIES + +=item possible_duplicates + +=item set_url + +=item add_cc + +=item blocks_obj + +=item set_status_whiteboard + +=item product_id + +=item error + +=item reset_assigned_to + +=item status_whiteboard + +=item create + +=item set_all + +=item set_reporter_accessible + +=item classification_id + +=item tags + +=item modify_keywords + +=item priority + +=item keyword_objects + +=item set_dependencies + +=item depends_on_obj + +=item cclist_accessible + +=item cc + +=item duplicates + +=item component_obj + +=item see_also + +=item groups + +=item default_bug_status + +=item related_bugs + +=item editable_bug_fields + +=item resolution + +=item lastdiffed + +=item classification + +=item alias + +=item op_sys + +=item remove_tag + +=item percentage_complete + +=item EmitDependList + +=item bug_alias_to_id + +=item set_qa_contact + +=item creation_ts + +=item set_version + +=item component_id + +=item new_bug_statuses + +=item set_remaining_time + +=item target_milestone + +=back diff --git a/Bugzilla/BugMail.pm b/Bugzilla/BugMail.pm index 5a2c9b788..9ebf3ea7d 100644 --- a/Bugzilla/BugMail.pm +++ b/Bugzilla/BugMail.pm @@ -5,10 +5,12 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::BugMail; +use 5.10.1; +use strict; +use warnings; + use Bugzilla::Error; use Bugzilla::User; use Bugzilla::Constants; @@ -22,6 +24,7 @@ use Date::Parse; use Date::Format; use Scalar::Util qw(blessed); use List::MoreUtils qw(uniq); +use Storable qw(dclone); use constant BIT_DIRECT => 1; use constant BIT_WATCHING => 2; @@ -35,9 +38,6 @@ sub relationships { return %relationships; } -# This is a bit of a hack, basically keeping the old system() -# cmd line interface. Should clean this up at some point. -# # args: bug_id, and an optional hash ref which may have keys for: # changer, owner, qa, reporter, cc # Optional hash contains values of people which will be forced to those @@ -85,6 +85,8 @@ sub Send { @diffs = _get_new_bugmail_fields($bug); } + my $comments = []; + if ($params->{dep_only}) { push(@diffs, { field_name => 'bug_status', old => $params->{changes}->{bug_status}->[0], @@ -101,11 +103,14 @@ sub Send { } else { push(@diffs, _get_diffs($bug, $end, \%user_cache)); - } - my $comments = $bug->comments({ after => $start, to => $end }); - # Skip empty comments. - @$comments = grep { $_->type || $_->body =~ /\S/ } @$comments; + $comments = $bug->comments({ after => $start, to => $end }); + # Skip empty comments. + @$comments = grep { $_->type || $_->body =~ /\S/ } @$comments; + + # 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 @@ -114,7 +119,10 @@ sub Send { # A user_id => roles hash to keep track of people. my %recipients; my %watching; - + + # We also record bugs that are referenced + my @referenced_bug_ids = (); + # Now we work out all the people involved with this bug, and note all of # the relationships in a hash. The keys are userids, the values are an # array of role constants. @@ -158,8 +166,17 @@ sub Send { $recipients{$uid}->{+REL_ASSIGNEE} = BIT_DIRECT if $uid; } } + + if ($change->{field_name} eq 'dependson' || $change->{field_name} eq 'blocked') { + push @referenced_bug_ids, split(/[\s,]+/, $change->{old}); + push @referenced_bug_ids, split(/[\s,]+/, $change->{new}); + } } + my $referenced_bugs = scalar(@referenced_bug_ids) + ? Bugzilla::Bug->new_from_list([uniq @referenced_bug_ids]) + : []; + # 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); @@ -169,21 +186,24 @@ sub Send { { 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); - - 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; + # We should not assume %recipients to have any entries. + 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)"); + + # 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 @@ -198,7 +218,6 @@ 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. @@ -207,12 +226,14 @@ sub Send { 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($user_id); # 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. @@ -229,7 +250,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? @@ -242,29 +263,24 @@ sub Send { $dep_ok = $user->can_see_bug($params->{blocker}->id) ? 1 : 0; } - # 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, - bug => $bug, - comments => $comments, - date => $date, - changer => $changer, - watchers => exists $watching{$user_id} ? - $watching{$user_id} : undef, - diffs => \@diffs, + # Email the user if the dep check passed. + if ($dep_ok) { + my $sent_mail = sendMail( + { to => $user, + bug => $bug, + comments => $comments, + date => $date, + changer => $changer, + watchers => exists $watching{$user_id} ? + $watching{$user_id} : undef, + diffs => \@diffs, rels_which_want => \%rels_which_want, + dep_only => $params->{dep_only}, + referenced_bugs => $referenced_bugs, }); + 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, @@ -276,27 +292,29 @@ 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} }; - my $date = $params->{date}; - my $changer = $params->{changer}; - my $watchingRef = $params->{watchers}; - my @diffs = @{ $params->{diffs} }; - my $relRef = $params->{rels_which_want}; + + my $user = $params->{to}; + my $bug = $params->{bug}; + my @send_comments = @{ $params->{comments} }; + my $date = $params->{date}; + my $changer = $params->{changer}; + my $watchingRef = $params->{watchers}; + my @diffs = @{ $params->{diffs} }; + my $relRef = $params->{rels_which_want}; + my $dep_only = $params->{dep_only}; + my $referenced_bugs = $params->{referenced_bugs}; # Only display changes the user is allowed see. my @display_diffs; foreach my $diff (@diffs) { my $add_diff = 0; - + if (grep { $_ eq $diff->{field_name} } TIMETRACKING_FIELDS) { $add_diff = 1 if $user->is_timetracker; } @@ -326,41 +344,98 @@ sub sendMail { my @watchingrel = map { $relationships{$_} } @reasons_watch; push(@headerrel, 'None') unless @headerrel; push(@watchingrel, 'None') unless @watchingrel; - push @watchingrel, map { user_id_to_login($_) } @$watchingRef; + push @watchingrel, map { Bugzilla::User->new($_)->login } @$watchingRef; my @changedfields = 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, 'attachments.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 => \@changedfields, - new_comments => \@send_comments, - threadingmarker => build_thread_marker($bug->id, $user->id, !$bug->lastdiffed), + changer => $changer, + diffs => \@display_diffs, + changedfields => \@changedfields, + referenced_bugs => $user->visible_bugs($referenced_bugs), + new_comments => \@send_comments, + threadingmarker => build_thread_marker($bug->id, $user->id, !$bug->lastdiffed), + 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}); + } + } + 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) @@ -446,8 +521,8 @@ sub _get_diffs { && $diff->{field_name} eq $changes[-1]->{field_name} && $diff->{bug_when} eq $changes[-1]->{bug_when} && $diff->{who} eq $changes[-1]->{who} - && ($diff->{attach_id} || 0) == ($changes[-1]->{attach_id} || 0) - && ($diff->{comment_id} || 0) == ($changes[-1]->{comment_id} || 0) + && ($diff->{attach_id} // 0) == ($changes[-1]->{attach_id} // 0) + && ($diff->{comment_id} // 0) == ($changes[-1]->{comment_id} // 0) ) { my $old_change = pop @changes; $diff->{old} = join_activity_entries($diff->{field_name}, $old_change->{old}, $diff->{old}); @@ -506,3 +581,36 @@ sub _get_new_bugmail_fields { } 1; + +=head1 NAME + +BugMail - Routines to generate email notifications when a bug is created or +modified. + +=head1 METHODS + +=over 4 + +=item C<enqueue> + +Serialises the variables required to generate bugmail and pushes the result to +the job-queue for processing by TheSchwartz. + +=item C<dequeue> + +When given serialised variables from the job-queue, recreates the objects from +the flattened hashes, generates the bugmail, and sends it. + +=back + +=head1 B<Methods in need of POD> + +=over + +=item relationships + +=item sendMail + +=item Send + +=back diff --git a/Bugzilla/BugUrl.pm b/Bugzilla/BugUrl.pm index 8689e8a35..1e836ca1e 100644 --- a/Bugzilla/BugUrl.pm +++ b/Bugzilla/BugUrl.pm @@ -6,14 +6,19 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl; + +use 5.10.1; use strict; -use base qw(Bugzilla::Object); +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::Util; use Bugzilla::Error; use Bugzilla::Constants; use Bugzilla::Hook; +use URI; use URI::QueryParam; ############################### @@ -198,3 +203,17 @@ sub _check_value { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item should_handle + +=item class_for + +=item class + +=item bug_id + +=back diff --git a/Bugzilla/BugUrl/Bugzilla.pm b/Bugzilla/BugUrl/Bugzilla.pm index 4db37eb7f..402ff1509 100644 --- a/Bugzilla/BugUrl/Bugzilla.pm +++ b/Bugzilla/BugUrl/Bugzilla.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::Bugzilla; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); use Bugzilla::Error; use Bugzilla::Util; diff --git a/Bugzilla/BugUrl/Bugzilla/Local.pm b/Bugzilla/BugUrl/Bugzilla/Local.pm index 9631716ae..7b9cb6a4f 100644 --- a/Bugzilla/BugUrl/Bugzilla/Local.pm +++ b/Bugzilla/BugUrl/Bugzilla/Local.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::Bugzilla::Local; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl::Bugzilla); +use warnings; + +use parent qw(Bugzilla::BugUrl::Bugzilla); use Bugzilla::Error; use Bugzilla::Util; diff --git a/Bugzilla/BugUrl/Debian.pm b/Bugzilla/BugUrl/Debian.pm index cce4c25fd..2b611aa57 100644 --- a/Bugzilla/BugUrl/Debian.pm +++ b/Bugzilla/BugUrl/Debian.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::Debian; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### diff --git a/Bugzilla/BugUrl/GitHub.pm b/Bugzilla/BugUrl/GitHub.pm index eeffa2ed6..f14f1d6b0 100644 --- a/Bugzilla/BugUrl/GitHub.pm +++ b/Bugzilla/BugUrl/GitHub.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::GitHub; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### @@ -18,8 +22,10 @@ sub should_handle { # GitHub issue URLs have only one form: # https://github.com/USER_OR_TEAM_OR_ORGANIZATION_NAME/REPOSITORY_NAME/issues/111 + # GitHub pull request URLs have only one form: + # https://github.com/USER_OR_TEAM_OR_ORGANIZATION_NAME/REPOSITORY_NAME/pull/111 return (lc($uri->authority) eq 'github.com' - and $uri->path =~ m|^/[^/]+/[^/]+/issues/\d+$|) ? 1 : 0; + and $uri->path =~ m!^/[^/]+/[^/]+/(?:issues|pull)/\d+$!) ? 1 : 0; } sub _check_value { diff --git a/Bugzilla/BugUrl/Google.pm b/Bugzilla/BugUrl/Google.pm index 9c49f0dcc..71a9c46fb 100644 --- a/Bugzilla/BugUrl/Google.pm +++ b/Bugzilla/BugUrl/Google.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::Google; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### diff --git a/Bugzilla/BugUrl/JIRA.pm b/Bugzilla/BugUrl/JIRA.pm index f5f7ee5fa..e9d2a2d2a 100644 --- a/Bugzilla/BugUrl/JIRA.pm +++ b/Bugzilla/BugUrl/JIRA.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::JIRA; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### diff --git a/Bugzilla/BugUrl/Launchpad.pm b/Bugzilla/BugUrl/Launchpad.pm index 2ae2c383d..0362747a2 100644 --- a/Bugzilla/BugUrl/Launchpad.pm +++ b/Bugzilla/BugUrl/Launchpad.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::Launchpad; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### diff --git a/Bugzilla/BugUrl/MantisBT.pm b/Bugzilla/BugUrl/MantisBT.pm index 3d49ede69..60d3b578e 100644 --- a/Bugzilla/BugUrl/MantisBT.pm +++ b/Bugzilla/BugUrl/MantisBT.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::MantisBT; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### diff --git a/Bugzilla/BugUrl/SourceForge.pm b/Bugzilla/BugUrl/SourceForge.pm index fcc720049..acba0df28 100644 --- a/Bugzilla/BugUrl/SourceForge.pm +++ b/Bugzilla/BugUrl/SourceForge.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::SourceForge; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### diff --git a/Bugzilla/BugUrl/Trac.pm b/Bugzilla/BugUrl/Trac.pm index 8f6e9cd0e..fe74abf33 100644 --- a/Bugzilla/BugUrl/Trac.pm +++ b/Bugzilla/BugUrl/Trac.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::BugUrl::Trac; + +use 5.10.1; use strict; -use base qw(Bugzilla::BugUrl); +use warnings; + +use parent qw(Bugzilla::BugUrl); ############################### #### Methods #### diff --git a/Bugzilla/BugUserLastVisit.pm b/Bugzilla/BugUserLastVisit.pm new file mode 100644 index 000000000..d043b121a --- /dev/null +++ b/Bugzilla/BugUserLastVisit.pm @@ -0,0 +1,93 @@ +# 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::BugUserLastVisit; + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); + +##################################################################### +# Overriden Constants that are used as methods +##################################################################### + +use constant DB_TABLE => 'bug_user_last_visit'; +use constant DB_COLUMNS => qw( id user_id bug_id last_visit_ts ); +use constant UPDATE_COLUMNS => qw( last_visit_ts ); +use constant VALIDATORS => {}; +use constant LIST_ORDER => 'id'; +use constant NAME_FIELD => 'id'; + +# turn off auditing and exclude these objects from memcached +use constant { AUDIT_CREATES => 0, + AUDIT_UPDATES => 0, + AUDIT_REMOVES => 0, + USE_MEMCACHED => 0 }; + +##################################################################### +# Provide accessors for our columns +##################################################################### + +sub id { return $_[0]->{id} } +sub bug_id { return $_[0]->{bug_id} } +sub user_id { return $_[0]->{user_id} } +sub last_visit_ts { return $_[0]->{last_visit_ts} } + +sub user { + my $self = shift; + + $self->{user} //= Bugzilla::User->new({ id => $self->user_id, cache => 1 }); + return $self->{user}; +} + +1; +__END__ + +=head1 NAME + +Bugzilla::BugUserLastVisit - Model for BugUserLastVisit bug search data + +=head1 SYNOPSIS + + use Bugzilla::BugUserLastVisit; + + my $lv = Bugzilla::BugUserLastVisit->new($id); + + # Class Functions + $user = Bugzilla::BugUserLastVisit->create({ + bug_id => $bug_id, + user_id => $user_id, + last_visit_ts => $last_visit_ts + }); + +=head1 DESCRIPTION + +This package handles Bugzilla BugUserLastVisit. + +C<Bugzilla::BugUserLastVisit> is an implementation of L<Bugzilla::Object>, and +thus provides all the methods of L<Bugzilla::Object> in addition to the methods +listed below. + +=head1 METHODS + +=head2 Accessor Methods + +=over + +=item C<id> + +=item C<bug_id> + +=item C<user_id> + +=item C<last_visit_ts> + +=item C<user> + +=back diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm index 7df916b0c..0b8a48697 100644 --- a/Bugzilla/CGI.pm +++ b/Bugzilla/CGI.pm @@ -6,12 +6,17 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::CGI; + +use 5.10.1; use strict; -use base qw(CGI); +use warnings; + +use parent qw(CGI); use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Util; +use Bugzilla::Hook; use Bugzilla::Search::Recent; use File::Basename; @@ -53,7 +58,7 @@ sub new { # the rendering of pages. my $script = basename($0); if (my $path_info = $self->path_info) { - my @whitelist; + my @whitelist = ("rest.cgi"); Bugzilla::Hook::process('path_info_whitelist', { whitelist => \@whitelist }); if (!grep($_ eq $script, @whitelist)) { # IIS includes the full path to the script in PATH_INFO, @@ -120,7 +125,8 @@ sub canonicalise_query { my $esc_key = url_quote($key); foreach my $value ($self->param($key)) { - if (defined($value)) { + # Omit params with an empty value + if (defined($value) && $value ne '') { my $esc_value = url_quote($value); push(@parameters, "$esc_key=$esc_value"); @@ -233,11 +239,11 @@ sub check_etag { $possible_etag =~ s/^\"//g; $possible_etag =~ s/\"$//g; if ($possible_etag eq $valid_etag or $possible_etag eq '*') { - print $self->header(-ETag => $possible_etag, - -status => '304 Not Modified'); - exit; + return 1; } } + + return 0; } # Have to add the cookies in. @@ -270,28 +276,35 @@ sub multipart_start { } sub close_standby_message { - my ($self, $contenttype, $disposition) = @_; + my ($self, $contenttype, $disp, $disp_prefix, $extension) = @_; + $self->set_dated_content_disp($disp, $disp_prefix, $extension); if ($self->{_multipart_in_progress}) { print $self->multipart_end(); - print $self->multipart_start(-type => $contenttype, - -content_disposition => $disposition); + print $self->multipart_start(-type => $contenttype); } else { - print $self->header(-type => $contenttype, - -content_disposition => $disposition); + print $self->header($contenttype); } } # Override header so we can add the cookies in sub header { my $self = shift; + + my %headers; my $user = Bugzilla->user; # If there's only one parameter, then it's a Content-Type. if (scalar(@_) == 1) { - # Since we're adding parameters below, we have to name it. - unshift(@_, '-type' => shift(@_)); + %headers = ('-type' => shift(@_)); + } + else { + %headers = @_; + } + + if ($self->{'_content_disp'}) { + $headers{'-content_disposition'} = $self->{'_content_disp'}; } if (!$user->id && $user->authorizer->can_login @@ -308,7 +321,7 @@ sub header { # Add the cookies in if we have any if (scalar(@{$self->{Bugzilla_cookie_list}})) { - unshift(@_, '-cookie' => $self->{Bugzilla_cookie_list}); + $headers{'-cookie'} = $self->{Bugzilla_cookie_list}; } # Add Strict-Transport-Security (STS) header if this response @@ -322,24 +335,29 @@ sub header { { $sts_opts .= '; includeSubDomains'; } - unshift(@_, '-strict_transport_security' => $sts_opts); + + $headers{'-strict_transport_security'} = $sts_opts; } # Add X-Frame-Options header to prevent framing and subsequent # possible clickjacking problems. unless ($self->url_is_attachment_base) { - unshift(@_, '-x_frame_options' => 'SAMEORIGIN'); + $headers{'-x_frame_options'} = 'SAMEORIGIN'; } # 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'); + $headers{'-x_xss_protection'} = '1; mode=block'; # Add X-Content-Type-Options header to prevent browsers sniffing # the MIME type away from the declared Content-Type. - unshift(@_, '-x_content_type_options' => 'nosniff'); + $headers{'-x_content_type_options'} = 'nosniff'; - return $self->SUPER::header(@_) || ""; + Bugzilla::Hook::process('cgi_headers', + { cgi => $self, headers => \%headers } + ); + + return $self->SUPER::header(%headers) || ""; } sub param { @@ -356,10 +374,7 @@ sub param { if (!scalar(@result) && $self->request_method && $self->request_method eq 'POST') { - # Some servers fail to set the QUERY_STRING parameter, which - # causes undef issues - $ENV{'QUERY_STRING'} = '' unless exists $ENV{'QUERY_STRING'}; - @result = $self->SUPER::url_param(@_); + @result = $self->url_param(@_); } # Fix UTF-8-ness of input parameters. @@ -384,6 +399,14 @@ sub param { return $self->SUPER::param(@_); } +sub url_param { + my $self = shift; + # Some servers fail to set the QUERY_STRING parameter, which + # causes undef issues + $ENV{'QUERY_STRING'} //= ''; + return $self->SUPER::url_param(@_); +} + sub _fix_utf8 { my $input = shift; # The is_utf8 is here in case CGI gets smart about utf8 someday. @@ -554,6 +577,22 @@ sub url_is_attachment_base { 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; +} + ########################## # Vars TIEHASH Interface # ########################## @@ -628,7 +667,9 @@ I<Bugzilla::CGI> also includes additional functions. =item C<canonicalise_query(@exclude)> -This returns a sorted string of the parameters, suitable for use in a url. +This returns a sorted string of the parameters whose values are non-empty, +suitable for use in a url. + Values in C<@exclude> are not included in the result. =item C<send_cookie> @@ -670,8 +711,35 @@ If not specified, text/html is assumed. Ends a part of the multipart document, and starts another part. +=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 L<CGI|CGI>, L<CGI::Cookie|CGI::Cookie> + +=head1 B<Methods in need of POD> + +=over + +=item check_etag + +=item clean_search_url + +=item url_is_attachment_base + +=item should_set + +=item redirect_search_url + +=item param + +=item url_param + +=item header + +=back diff --git a/Bugzilla/Chart.pm b/Bugzilla/Chart.pm index e343a0535..3c69006aa 100644 --- a/Bugzilla/Chart.pm +++ b/Bugzilla/Chart.pm @@ -5,8 +5,6 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - # This module represents a chart. # # Note that it is perfectly legal for the 'lines' member variable of this @@ -15,6 +13,10 @@ use strict; # the same points. package Bugzilla::Chart; +use 5.10.1; +use strict; +use warnings; + use Bugzilla::Error; use Bugzilla::Util; use Bugzilla::Series; @@ -426,3 +428,29 @@ sub dump { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item remove + +=item add + +=item dump + +=item readData + +=item getSeriesIDs + +=item data + +=item init + +=item getVisibleSeries + +=item generateDateProgression + +=item sum + +=back diff --git a/Bugzilla/Classification.pm b/Bugzilla/Classification.pm index 2b35a8839..09f71baaf 100644 --- a/Bugzilla/Classification.pm +++ b/Bugzilla/Classification.pm @@ -5,23 +5,27 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Classification; +use 5.10.1; +use strict; +use warnings; + use Bugzilla::Constants; use Bugzilla::Field; use Bugzilla::Util; use Bugzilla::Error; use Bugzilla::Product; -use base qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object Exporter); +use parent qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object Exporter); @Bugzilla::Classification::EXPORT = qw(sort_products_by_classification); ############################### #### Initialization #### ############################### +use constant IS_CONFIG => 1; + use constant DB_TABLE => 'classifications'; use constant LIST_ORDER => 'sortkey, name'; @@ -47,6 +51,7 @@ use constant VALIDATORS => { ############################### #### Constructors ##### ############################### + sub remove_from_db { my $self = shift; my $dbh = Bugzilla->dbh; @@ -54,9 +59,19 @@ sub remove_from_db { ThrowUserError("classification_not_deletable") if ($self->id == 1); $dbh->bz_start_transaction(); + # Reclassify products to the default classification, if needed. - $dbh->do("UPDATE products SET classification_id = 1 - WHERE classification_id = ?", undef, $self->id); + my $product_ids = $dbh->selectcol_arrayref( + 'SELECT id FROM products WHERE classification_id = ?', undef, $self->id); + + if (@$product_ids) { + $dbh->do('UPDATE products SET classification_id = 1 WHERE ' + . $dbh->sql_in('id', $product_ids)); + foreach my $id (@$product_ids) { + Bugzilla->memcached->clear({ table => 'products', id => $id }); + } + Bugzilla->memcached->clear_config(); + } $self->SUPER::remove_from_db(); @@ -259,3 +274,21 @@ A Classification is a higher-level grouping of Products. =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item set_description + +=item sortkey + +=item set_name + +=item description + +=item remove_from_db + +=item set_sortkey + +=back diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm index 30ec1cb82..b036907d7 100644 --- a/Bugzilla/Comment.pm +++ b/Bugzilla/Comment.pm @@ -5,18 +5,22 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Comment; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent 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); ############################### @@ -78,21 +82,94 @@ use constant VALIDATOR_DEPENDENCIES => { sub update { my $self = shift; - my $changes = $self->SUPER::update(@_); - $self->bug->_sync_fulltext( update_comments => 1); + 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) . ' ' . + $dbh->sql_group_by('comment_id')); + foreach my $row (@$rows) { + $comment_map{$row->[0]}->{tags} = [ split(/,/, $row->[1]) ]; + } + # Also sets the 'tags' attribute for comments which have no entry + # in the longdescs_tags table, else calling $comment->tags will + # trigger another SQL query again. + $comment_map{$_}->{tags} ||= [] foreach @comment_ids; + } } ############################### @@ -112,6 +189,43 @@ sub work_time { sub type { return $_[0]->{'type'}; } sub extra_data { return $_[0]->{'extra_data'} } +sub tags { + my ($self) = @_; + state $comment_taggers_group = Bugzilla->params->{'comment_taggers_group'}; + return [] unless $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) = @_; + state $comment_taggers_group = Bugzilla->params->{'comment_taggers_group'}; + return 0 unless $comment_taggers_group; + return $self->{collapsed} if exists $self->{collapsed}; + + state $collapsed_comment_tags = Bugzilla->params->{'collapsed_comment_tags'}; + $self->{collapsed} = 0; + Bugzilla->request_cache->{comment_tags_collapsed} + ||= [ split(/\s*,\s*/, $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; @@ -129,13 +243,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'}); + $self->{'author'} + ||= new Bugzilla::User({ id => $self->{'who'}, cache => 1 }); return $self->{'author'}; } @@ -167,6 +283,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 # ############## @@ -297,6 +433,18 @@ sub _check_thetext { $thetext =~ s/\s*$//s; $thetext =~ s/\r\n?/\n/g; # Get rid of \r. + # Characters above U+FFFF cannot be stored by MySQL older than 5.5.3 as they + # require the new utf8mb4 character set. Other DB servers are handling them + # without any problem. So we need to replace these characters if we use MySQL, + # else the comment is truncated. + # XXX - Once we use utf8mb4 for comments, this hack for MySQL can go away. + state $is_mysql = Bugzilla->dbh->isa('Bugzilla::DB::Mysql') ? 1 : 0; + if ($is_mysql) { + # Perl 5.13.8 and older complain about non-characters. + no warnings 'utf8'; + $thetext =~ s/([\x{10000}-\x{10FFFF}])/"\x{FDD0}[" . uc(sprintf('U+%04x', ord($1))) . "]\x{FDD1}"/eg; + } + ThrowUserError('comment_too_long') if length($thetext) > MAX_COMMENT_LENGTH; return $thetext; } @@ -309,6 +457,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) = @_; @@ -323,7 +482,7 @@ sub count { undef, $self->bug_id, $self->creation_ts); return --$self->{'count'}; -} +} 1; @@ -369,7 +528,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> @@ -384,6 +543,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 @@ -417,3 +624,29 @@ A string, the full text of the comment as it would be displayed to an end-user. =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item set_type + +=item bug + +=item set_extra_data + +=item set_is_private + +=item attachment + +=item is_about_attachment + +=item extra_data + +=item preload + +=item type + +=item update + +=back diff --git a/Bugzilla/Comment/TagWeights.pm b/Bugzilla/Comment/TagWeights.pm new file mode 100644 index 000000000..7dba53e34 --- /dev/null +++ b/Bugzilla/Comment/TagWeights.pm @@ -0,0 +1,78 @@ +# 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 warnings; + +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 => { }; + +# There's no gain to caching these objects +use constant USE_MEMCACHED => 0; + +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 1ce4e02ea..9bc0a4493 100644 --- a/Bugzilla/Component.pm +++ b/Bugzilla/Component.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Component; + +use 5.10.1; use strict; -use base qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object); +use warnings; + +use parent qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object); use Bugzilla::Constants; use Bugzilla::Util; @@ -339,23 +343,16 @@ 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; - return if !$self->{'initialqacontact'}; - - if (!defined $self->{'default_qa_contact'}) { - $self->{'default_qa_contact'} = - new Bugzilla::User($self->{'initialqacontact'}); - } - return $self->{'default_qa_contact'}; + return unless $self->{'initialqacontact'}; + return $self->{'default_qa_contact'} + ||= new Bugzilla::User({id => $self->{'initialqacontact'}, cache => 1 }); } sub flag_types { @@ -650,3 +647,19 @@ Component.pm represents a Product Component object. =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item is_set_on_bug + +=item product_id + +=item set_is_active + +=item description + +=item is_active + +=back diff --git a/Bugzilla/Config.pm b/Bugzilla/Config.pm index 150996e05..1e22b5239 100644 --- a/Bugzilla/Config.pm +++ b/Bugzilla/Config.pm @@ -7,14 +7,21 @@ package Bugzilla::Config; +use 5.10.1; use strict; +use warnings; + +use parent qw(Exporter); +use autodie qw(:default); -use base qw(Exporter); use Bugzilla::Constants; use Bugzilla::Hook; -use Bugzilla::Install::Filesystem qw(fix_file_permissions); -use Data::Dumper; +use Bugzilla::Util qw(trick_taint); + +use JSON::XS; +use File::Slurp; use File::Temp; +use File::Basename; # Don't export localvars by default - people should have to explicitly # ask for it, as a (probably futile) attempt to stop code using it @@ -91,8 +98,35 @@ sub SetParam { sub update_params { my ($params) = @_; my $answer = Bugzilla->installation_answers; + my $datadir = bz_locations()->{'datadir'}; + my $param; + + # If the old data/params file using Data::Dumper output still exists, + # read it. It will be deleted once the parameters are stored in the new + # data/params.json file. + my $old_file = "$datadir/params"; + + if (-e $old_file) { + require Safe; + my $s = new Safe; + + $s->rdo($old_file); + die "Error reading $old_file: $!" if $!; + die "Error evaluating $old_file: $@" if $@; + + # Now read the param back out from the sandbox. + $param = \%{ $s->varglob('param') }; + } + else { + # Rename params.js to params.json if checksetup.pl + # was executed with an earlier version of this change + rename "$old_file.js", "$old_file.json" + if -e "$old_file.js" && !-e "$old_file.json"; + + # Read the new data/params.json file. + $param = read_param_file(); + } - my $param = read_param_file(); my %new_params; # If we didn't return any param values, then this is a new installation. @@ -151,16 +185,19 @@ sub update_params { } # Old mail_delivery_method choices contained no uppercase characters - if (exists $param->{'mail_delivery_method'} - && $param->{'mail_delivery_method'} !~ /[A-Z]/) { - my $method = $param->{'mail_delivery_method'}; - my %translation = ( - 'sendmail' => 'Sendmail', - 'smtp' => 'SMTP', - 'qmail' => 'Qmail', - 'testfile' => 'Test', - 'none' => 'None'); - $param->{'mail_delivery_method'} = $translation{$method}; + my $mta = $param->{'mail_delivery_method'}; + if ($mta) { + if ($mta !~ /[A-Z]/) { + my %translation = ( + 'sendmail' => 'Sendmail', + 'smtp' => 'SMTP', + 'qmail' => 'Qmail', + 'testfile' => 'Test', + 'none' => 'None'); + $param->{'mail_delivery_method'} = $translation{$mta}; + } + # This will force the parameter to be reset to its default value. + delete $param->{'mail_delivery_method'} if $param->{'mail_delivery_method'} eq 'Qmail'; } # Convert the old "ssl" parameter to the new "ssl_redirect" parameter. @@ -196,6 +233,9 @@ sub update_params { $param->{'utf8'} = 1 if $new_install; + # Bug 452525: OR based groups are on by default for new installations + $param->{'or_groups'} = 1 if $new_install; + # --- REMOVE OLD PARAMS --- my %oldparams; @@ -207,7 +247,6 @@ sub update_params { } # Write any old parameters to old-params.txt - my $datadir = bz_locations()->{'datadir'}; my $old_param_file = "$datadir/old-params.txt"; if (scalar(keys %oldparams)) { my $op_file = new IO::File($old_param_file, '>>', 0600) @@ -217,12 +256,9 @@ sub update_params { " and so have been\nmoved from your parameters file into", " $old_param_file:\n"; - local $Data::Dumper::Terse = 1; - local $Data::Dumper::Indent = 0; - my $comma = ""; foreach my $item (keys %oldparams) { - print $op_file "\n\n$item:\n" . Data::Dumper->Dump([$oldparams{$item}]) . "\n"; + print $op_file "\n\n$item:\n" . $oldparams{$item} . "\n"; print "${comma}$item"; $comma = ", "; } @@ -253,6 +289,11 @@ sub update_params { write_params($param); + if (-e $old_file) { + unlink $old_file; + say "$old_file has been converted into $old_file.json, using the JSON format."; + } + # Return deleted params and values so that checksetup.pl has a chance # to convert old params to new data. return %oldparams; @@ -261,24 +302,15 @@ sub update_params { sub write_params { my ($param_data) = @_; $param_data ||= Bugzilla->params; + my $param_file = bz_locations()->{'datadir'} . '/params.json'; - my $datadir = bz_locations()->{'datadir'}; - my $param_file = "$datadir/params"; - - local $Data::Dumper::Sortkeys = 1; - - my ($fh, $tmpname) = File::Temp::tempfile('params.XXXXX', - DIR => $datadir ); - - print $fh (Data::Dumper->Dump([$param_data], ['*param'])) - || die "Can't write param file: $!"; + my $json_data = JSON::XS->new->canonical->pretty->encode($param_data); + write_file($param_file, { binmode => ':utf8', atomic => 1 }, \$json_data); - close $fh; - - 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. @@ -287,21 +319,25 @@ sub write_params { sub read_param_file { my %params; - my $datadir = bz_locations()->{'datadir'}; - if (-e "$datadir/params") { - # Note that checksetup.pl sets file permissions on '$datadir/params' - - # Using Safe mode is _not_ a guarantee of safety if someone does - # manage to write to the file. However, it won't hurt... - # See bug 165144 for not needing to eval this at all - my $s = new Safe; - - $s->rdo("$datadir/params"); - die "Error reading $datadir/params: $!" if $!; - die "Error evaluating $datadir/params: $@" if $@; - - # Now read the param back out from the sandbox - %params = %{$s->varglob('param')}; + my $file = bz_locations()->{'datadir'} . '/params.json'; + + if (-e $file) { + my $data; + read_file($file, binmode => ':utf8', buf_ref => \$data); + + # If params.json has been manually edited and e.g. some quotes are + # missing, we don't want JSON::XS to leak the content of the file + # to all users in its error message, so we have to eval'uate it. + %params = eval { %{JSON::XS->new->decode($data)} }; + if ($@) { + my $error_msg = (basename($0) eq 'checksetup.pl') ? + $@ : 'run checksetup.pl to see the details.'; + die "Error parsing $file: $error_msg"; + } + # JSON::XS doesn't detaint data for us. + foreach my $key (keys %params) { + trick_taint($params{$key}) if defined $params{$key}; + } } elsif ($ENV{'SERVER_SOFTWARE'}) { # We're in a CGI, but the params file doesn't exist. We can't @@ -311,7 +347,7 @@ sub read_param_file { # so that the user sees the error. require CGI::Carp; CGI::Carp->import('fatalsToBrowser'); - die "The $datadir/params file does not exist." + die "The $file file does not exist." . ' You probably need to run checksetup.pl.', } return \%params; @@ -367,7 +403,7 @@ specified. Description: Writes the parameters to disk. Params: C<$params> (optional) - A hashref to write to the disk - instead of C<Bugzilla->params>. Used only by + instead of C<Bugzilla-E<gt>params>. Used only by C<update_params>. Returns: nothing @@ -375,11 +411,13 @@ Returns: nothing =item C<read_param_file()> Description: Most callers should never need this. This is used - by C<Bugzilla->params> to directly read C<$datadir/params> - and load it into memory. Use C<Bugzilla->params> instead. + by C<Bugzilla-E<gt>params> to directly read C<$datadir/params.json> + and load it into memory. Use C<Bugzilla-E<gt>params> instead. Params: none -Returns: A hashref containing the current params in C<$datadir/params>. +Returns: A hashref containing the current params in C<$datadir/params.json>. + +=item C<param_panels()> =back diff --git a/Bugzilla/Config/Admin.pm b/Bugzilla/Config/Admin.pm index 7be3e54d1..41d929298 100644 --- a/Bugzilla/Config/Admin.pm +++ b/Bugzilla/Config/Admin.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::Admin; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; @@ -32,6 +34,13 @@ sub get_param_list { name => 'allowuserdeletion', type => 'b', default => 0 + }, + + { + name => 'last_visit_keep_days', + type => 't', + default => 10, + checker => \&check_numeric }); return @param_list; } diff --git a/Bugzilla/Config/Advanced.pm b/Bugzilla/Config/Advanced.pm index fa5b7d249..8356c3361 100644 --- a/Bugzilla/Config/Advanced.pm +++ b/Bugzilla/Config/Advanced.pm @@ -6,7 +6,10 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Config::Advanced; + +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; diff --git a/Bugzilla/Config/Attachment.pm b/Bugzilla/Config/Attachment.pm index ba19f7782..580ec46d9 100644 --- a/Bugzilla/Config/Attachment.pm +++ b/Bugzilla/Config/Attachment.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::Attachment; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; diff --git a/Bugzilla/Config/Auth.pm b/Bugzilla/Config/Auth.pm index 19ba59b0c..78d719b15 100644 --- a/Bugzilla/Config/Auth.pm +++ b/Bugzilla/Config/Auth.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::Auth; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; @@ -73,9 +75,15 @@ sub get_param_list { }, { + name => 'webservice_email_filter', + type => 'b', + default => 0 + }, + + { name => 'emailregexp', type => 't', - default => q:^[\\w\\.\\+\\-=]+@[\\w\\.\\-]+\\.[\\w\\-]+$:, + default => q:^[\\w\\.\\+\\-=']+@[\\w\\.\\-]+\\.[\\w\\-]+$:, checker => \&check_regexp }, @@ -106,7 +114,14 @@ sub get_param_list { 'letters_numbers_specialchars' ], default => 'no_constraints', checker => \&check_multi - } ); + }, + + { + name => 'password_check_on_login', + type => 'b', + default => '1' + }, + ); return @param_list; } diff --git a/Bugzilla/Config/BugChange.pm b/Bugzilla/Config/BugChange.pm index 68cfe5676..0acdc0ce4 100644 --- a/Bugzilla/Config/BugChange.pm +++ b/Bugzilla/Config/BugChange.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::BugChange; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; use Bugzilla::Status; diff --git a/Bugzilla/Config/BugFields.pm b/Bugzilla/Config/BugFields.pm index 7ae9ae355..ef2faa64b 100644 --- a/Bugzilla/Config/BugFields.pm +++ b/Bugzilla/Config/BugFields.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::BugFields; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; use Bugzilla::Field; @@ -83,7 +85,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 e1c2c8c40..414894773 100644 --- a/Bugzilla/Config/Common.pm +++ b/Bugzilla/Config/Common.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::Common; +use 5.10.1; use strict; +use warnings; use Email::Address; use Socket; @@ -18,15 +20,16 @@ use Bugzilla::Field; use Bugzilla::Group; use Bugzilla::Status; -use base qw(Exporter); +use parent qw(Exporter); @Bugzilla::Config::Common::EXPORT = qw(check_multi check_numeric check_regexp check_url check_group check_sslbase check_priority check_severity check_platform check_opsys check_shadowdb check_urlbase check_webdotbase - check_user_verify_class check_ip check_smtp_server + check_user_verify_class check_ip check_font_file check_mail_delivery_method check_notification check_utf8 check_bug_status check_smtp_auth check_theschwartz_available check_maxattachmentsize check_email check_smtp_ssl + check_comment_taggers_group check_smtp_server ); # Checking functions for the various values @@ -97,7 +100,7 @@ sub check_sslbase { my $iaddr = inet_aton($host) || return "The host $host cannot be resolved"; my $sin = sockaddr_in($port, $iaddr); if (!connect(SOCK, $sin)) { - return "Failed to connect to $host:$port; unable to enable SSL"; + return "Failed to connect to $host:$port ($!); unable to enable SSL"; } close(SOCK); } @@ -241,6 +244,20 @@ sub check_webdotbase { return ""; } +sub check_font_file { + my ($font) = @_; + $font = trim($font); + return '' unless $font; + + if ($font !~ /\.ttf$/) { + return "The file must point to a TrueType font file (its extension must be .ttf)" + } + if (! -f $font) { + return "The file '$font' cannot be found. Make sure you typed the full path to the file" + } + return ''; +} + sub check_user_verify_class { # doeditparams traverses the list of params, and for each one it checks, # then updates. This means that if one param checker wants to look at @@ -335,7 +352,21 @@ sub check_smtp_server { return "Invalid port. It must be an integer (typically 25, 465 or 587)"; } } - return ""; + trick_taint($host); + # Let's first try to connect using SSL. If this fails, we fall back to + # an unencrypted connection. + foreach my $method (['Net::SMTP::SSL', 465], ['Net::SMTP', 25]) { + my ($class, $default_port) = @$method; + next if $class eq 'Net::SMTP::SSL' && !Bugzilla->feature('smtp_ssl'); + eval "require $class"; + my $smtp = $class->new($host, Port => $port || $default_port, Timeout => 5); + if ($smtp) { + # The connection works! + $smtp->quit; + return ''; + } + } + return "Cannot connect to $host" . ($port ? " using port $port" : ""); } sub check_smtp_auth { @@ -365,6 +396,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: @@ -463,4 +502,63 @@ 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 + +=head1 B<Methods in need of POD> + +=over + +=item check_notification + +=item check_priority + +=item check_ip + +=item check_user_verify_class + +=item check_bug_status + +=item check_shadowdb + +=item check_smtp_server + +=item check_smtp_auth + +=item check_url + +=item check_urlbase + +=item check_email + +=item check_webdotbase + +=item check_font_file + +=item get_param_list + +=item check_maxattachmentsize + +=item check_utf8 + +=item check_group + +=item check_opsys + +=item check_platform + +=item check_severity + +=item check_sslbase + +=item check_mail_delivery_method + +=item check_theschwartz_available + +=item check_smtp_ssl + =back diff --git a/Bugzilla/Config/Core.pm b/Bugzilla/Config/Core.pm index f2510d2b2..654e569ba 100644 --- a/Bugzilla/Config/Core.pm +++ b/Bugzilla/Config/Core.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::Core; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; diff --git a/Bugzilla/Config/DependencyGraph.pm b/Bugzilla/Config/DependencyGraph.pm index cc61e3588..c815822f3 100644 --- a/Bugzilla/Config/DependencyGraph.pm +++ b/Bugzilla/Config/DependencyGraph.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::DependencyGraph; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; @@ -19,9 +21,16 @@ sub get_param_list { { name => 'webdotbase', type => 't', - default => 'http://www.research.att.com/~north/cgi-bin/webdot.cgi/%urlbase%', + default => '', checker => \&check_webdotbase - } ); + }, + + { + name => 'font_file', + type => 't', + default => '', + checker => \&check_font_file + }); return @param_list; } diff --git a/Bugzilla/Config/General.pm b/Bugzilla/Config/General.pm index 6e00b202a..380680590 100644 --- a/Bugzilla/Config/General.pm +++ b/Bugzilla/Config/General.pm @@ -6,7 +6,11 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Config::General; + +use 5.10.1; use strict; +use warnings; + use Bugzilla::Config::Common; our $sortkey = 150; @@ -21,13 +25,6 @@ use constant get_param_list => ( }, { - name => 'docs_urlbase', - type => 't', - default => 'docs/%lang%/html/', - checker => \&check_url - }, - - { name => 'utf8', type => 'b', default => '0', diff --git a/Bugzilla/Config/GroupSecurity.pm b/Bugzilla/Config/GroupSecurity.pm index f0b4ac808..e827834a0 100644 --- a/Bugzilla/Config/GroupSecurity.pm +++ b/Bugzilla/Config/GroupSecurity.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::GroupSecurity; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; use Bugzilla::Group; @@ -55,7 +57,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', @@ -74,6 +84,12 @@ sub get_param_list { name => 'strict_isolation', type => 'b', default => 0 + }, + + { + name => 'or_groups', + type => 'b', + default => 0 } ); return @param_list; } @@ -83,4 +99,5 @@ sub _get_all_group_names { unshift(@group_names, ''); return \@group_names; } + 1; diff --git a/Bugzilla/Config/LDAP.pm b/Bugzilla/Config/LDAP.pm index daa7f72bb..0bc8240df 100644 --- a/Bugzilla/Config/LDAP.pm +++ b/Bugzilla/Config/LDAP.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::LDAP; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; diff --git a/Bugzilla/Config/MTA.pm b/Bugzilla/Config/MTA.pm index e6e9505a3..467bdab3f 100644 --- a/Bugzilla/Config/MTA.pm +++ b/Bugzilla/Config/MTA.pm @@ -7,16 +7,11 @@ package Bugzilla::Config::MTA; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; -# Return::Value 1.666002 pollutes the error log with warnings about this -# deprecated module. We have to set NO_CLUCK = 1 before loading Email::Send -# to disable these warnings. -BEGIN { - $Return::Value::NO_CLUCK = 1; -} -use Email::Send; our $sortkey = 1200; @@ -26,9 +21,7 @@ sub get_param_list { { name => 'mail_delivery_method', type => 's', - # Bugzilla is not ready yet to send mails to newsgroups, and 'IO' - # is of no use for now as we already have our own 'Test' mode. - choices => [grep {$_ ne 'NNTP' && $_ ne 'IO'} Email::Send->new()->all_mailers(), 'None'], + choices => ['Sendmail', 'SMTP', 'Test', 'None'], default => 'Sendmail', checker => \&check_mail_delivery_method }, diff --git a/Bugzilla/Config/Memcached.pm b/Bugzilla/Config/Memcached.pm new file mode 100644 index 000000000..292803d86 --- /dev/null +++ b/Bugzilla/Config/Memcached.pm @@ -0,0 +1,33 @@ +# 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::Config::Memcached; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla::Config::Common; + +our $sortkey = 1550; + +sub get_param_list { + return ( + { + name => 'memcached_servers', + type => 't', + default => '' + }, + { + name => 'memcached_namespace', + type => 't', + default => 'bugzilla:', + }, + ); +} + +1; diff --git a/Bugzilla/Config/PatchViewer.pm b/Bugzilla/Config/PatchViewer.pm deleted file mode 100644 index 08e8028f3..000000000 --- a/Bugzilla/Config/PatchViewer.pm +++ /dev/null @@ -1,51 +0,0 @@ -# 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::Config::PatchViewer; - -use strict; - -use Bugzilla::Config::Common; - -our $sortkey = 1300; - -sub get_param_list { - my $class = shift; - my @param_list = ( - { - name => 'cvsroot', - type => 't', - default => '', - }, - - { - name => 'cvsroot_get', - type => 't', - default => '', - }, - - { - name => 'bonsai_url', - type => 't', - default => '' - }, - - { - name => 'lxr_url', - type => 't', - default => '' - }, - - { - name => 'lxr_root', - type => 't', - default => '', - } ); - return @param_list; -} - -1; diff --git a/Bugzilla/Config/Query.pm b/Bugzilla/Config/Query.pm index fe54d67ab..f18bb90df 100644 --- a/Bugzilla/Config/Query.pm +++ b/Bugzilla/Config/Query.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::Query; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; @@ -25,13 +27,6 @@ sub get_param_list { }, { - name => 'mostfreqthreshold', - type => 't', - default => '2', - checker => \&check_numeric - }, - - { name => 'mybugstemplate', type => 't', default => 'buglist.cgi?resolution=---&emailassigned_to1=1&emailreporter1=1&emailtype1=exact&email1=%userid%' diff --git a/Bugzilla/Config/RADIUS.pm b/Bugzilla/Config/RADIUS.pm index 312be1ded..8e30b07a9 100644 --- a/Bugzilla/Config/RADIUS.pm +++ b/Bugzilla/Config/RADIUS.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::RADIUS; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; diff --git a/Bugzilla/Config/ShadowDB.pm b/Bugzilla/Config/ShadowDB.pm index c0ff59096..5dbbb5202 100644 --- a/Bugzilla/Config/ShadowDB.pm +++ b/Bugzilla/Config/ShadowDB.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::ShadowDB; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; diff --git a/Bugzilla/Config/UserMatch.pm b/Bugzilla/Config/UserMatch.pm index 5b98a25ea..3f74a7c44 100644 --- a/Bugzilla/Config/UserMatch.pm +++ b/Bugzilla/Config/UserMatch.pm @@ -7,7 +7,9 @@ package Bugzilla::Config::UserMatch; +use 5.10.1; use strict; +use warnings; use Bugzilla::Config::Common; diff --git a/Bugzilla/Constants.pm b/Bugzilla/Constants.pm index 179b765f2..837ed895d 100644 --- a/Bugzilla/Constants.pm +++ b/Bugzilla/Constants.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Constants; + +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent qw(Exporter); # For bz_locations use File::Basename; @@ -15,12 +19,15 @@ use Memoize; @Bugzilla::Constants::EXPORT = qw( BUGZILLA_VERSION + REST_DOC REMOTE_FILE LOCAL_FILE bz_locations + CONCATENATE_ASSETS + IS_NULL NOT_NULL @@ -66,6 +73,9 @@ use Memoize; COMMENT_COLS MAX_COMMENT_LENGTH + MIN_COMMENT_TAG_LENGTH + MAX_COMMENT_TAG_LENGTH + CMT_NORMAL CMT_DUPE_OF CMT_HAS_DUPE @@ -101,9 +111,12 @@ 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_INTEGER + FIELD_TYPE_HIGHEST_PLUS_ONE EMPTY_DATETIME_REGEX @@ -117,12 +130,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 @@ -162,6 +177,7 @@ use Memoize; MAX_POSSIBLE_DUPLICATES MAX_ATTACH_FILENAME_LENGTH MAX_QUIP_LENGTH + MAX_WEBDOT_BUGS PASSWORD_DIGEST_ALGORITHM PASSWORD_SALT_LENGTH @@ -175,6 +191,8 @@ use Memoize; AUDIT_CREATE AUDIT_REMOVE + + MOST_FREQUENT_THRESHOLD ); @Bugzilla::Constants::EXPORT_OK = qw(contenttypes); @@ -182,12 +200,21 @@ use Memoize; # CONSTANTS # # Bugzilla version -use constant BUGZILLA_VERSION => "4.4.9"; +use constant BUGZILLA_VERSION => "5.0"; + +# A base link to the current REST Documentation. We place it here +# as it will need to be updated to whatever the current release is. +use constant REST_DOC => "http://www.bugzilla.org/docs/tip/en/html/api/"; # Location of the remote and local XML files to track new releases. use constant REMOTE_FILE => 'http://updates.bugzilla.org/bugzilla-update.xml'; use constant LOCAL_FILE => 'bugzilla-update.xml'; # Relative to datadir. +# When true CSS and JavaScript assets will be concatanted and minified at +# run-time, to reduce the number of requests required to render a page. +# Setting this to a false value can help debugging. +use constant CONCATENATE_ASSETS => 1; + # These are unique values that are unlikely to match a string or a number, # to be used in criteria for match() functions and other things. They start # and end with spaces because most Bugzilla stuff has trim() called on it, @@ -283,6 +310,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; @@ -382,6 +413,11 @@ 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_INTEGER => 10; +# Add new field types above this line, and change the below value in the +# obvious fashion +use constant FIELD_TYPE_HIGHEST_PLUS_ONE => 11; use constant EMPTY_DATETIME_REGEX => qr/^[0\-:\sA-Za-z]+$/; @@ -396,8 +432,7 @@ use constant ABNORMAL_SELECTS => { # The fields from fielddefs that are blocked from non-timetracking users. # work_time is sometimes called actual_time. use constant TIMETRACKING_FIELDS => - qw(estimated_time remaining_time work_time actual_time - percentage_complete deadline); + qw(estimated_time remaining_time work_time actual_time percentage_complete); # The maximum number of days a token will remain valid. use constant MAX_TOKEN_AGE => 3; @@ -412,8 +447,8 @@ use constant MAX_LOGIN_ATTEMPTS => 5; # account is locked. use constant LOGIN_LOCKOUT_INTERVAL => 30; -# The time in minutes a user must wait before he can request another email to -# create a new account or change his password. +# The time in minutes a user must wait before they can request another email to +# create a new account or change their password. use constant ACCOUNT_CHANGE_INTERVAL => 10; # The maximum number of seconds the Strict-Transport-Security header @@ -450,6 +485,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. @@ -458,6 +494,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,13 +599,16 @@ use constant MAX_ATTACH_FILENAME_LENGTH => 255; # Maximum length of a quip. use constant MAX_QUIP_LENGTH => 512; +# 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 -# effect until a user changes his password. +# effect until a user logs in or changes their password. use constant PASSWORD_DIGEST_ALGORITHM => 'SHA-256'; -# How long of a salt should we use? Note that if you change this, none -# of your users will be able to log in until they reset their passwords. +# How long of a salt should we use? Note that if you change this, it +# won't take effect until a user logs in or changes their password. use constant PASSWORD_SALT_LENGTH => 8; # Certain scripts redirect to GET even if the form was submitted originally @@ -577,7 +617,7 @@ use constant PASSWORD_SALT_LENGTH => 8; # See http://support.microsoft.com/kb/208427 for why MSIE is different use constant CGI_URI_LIMIT => ($ENV{'HTTP_USER_AGENT'} || '') =~ /MSIE/ ? 2083 : 8000; -# If the user isn't allowed to change a field, we must tell him who can. +# If the user isn't allowed to change a field, we must tell them who can. # We store the required permission set into the $PrivilegesRequired # variable which gets passed to the error template. @@ -591,6 +631,10 @@ use constant PRIVILEGES_REQUIRED_EMPOWERED => 3; use constant AUDIT_CREATE => '__create__'; use constant AUDIT_REMOVE => '__remove__'; +# The minimum number of duplicates a bug needs to show up +# on the "Most frequently reported bugs" page. +use constant MOST_FREQUENT_THRESHOLD => 2; + sub bz_locations { # Force memoize() to re-compute data per project, to avoid # sharing the same data across different installations. @@ -651,6 +695,7 @@ sub _bz_locations { # The script should really generate these graphs directly... 'webdotdir' => "$datadir/webdot", 'extensionsdir' => "$libpath/extensions", + 'assetsdir' => "$datadir/assets", }; } @@ -659,3 +704,15 @@ sub _bz_locations { BEGIN { memoize('_bz_locations') }; 1; + +=head1 B<Methods in need of POD> + +=over + +=item DB_MODULE + +=item contenttypes + +=item bz_locations + +=back diff --git a/Bugzilla/DB.pm b/Bugzilla/DB.pm index 248312e12..c9eaf6e9e 100644 --- a/Bugzilla/DB.pm +++ b/Bugzilla/DB.pm @@ -7,20 +7,24 @@ package Bugzilla::DB; +use 5.10.1; use strict; +use warnings; use DBI; # Inherit the DB class from DBI::db. -use base qw(DBI::db); +use parent -norequire, qw(DBI::db); use Bugzilla::Constants; +use Bugzilla::Mailer; use Bugzilla::Install::Requirements; -use Bugzilla::Install::Util qw(vers_cmp install_string); +use Bugzilla::Install::Util qw(install_string); use Bugzilla::Install::Localconfig; use Bugzilla::Util; use Bugzilla::Error; use Bugzilla::DB::Schema; +use Bugzilla::Version; use List::Util qw(max); use Storable qw(dclone); @@ -1210,12 +1214,13 @@ sub bz_start_transaction { sub bz_commit_transaction { my ($self) = @_; - + if ($self->{private_bz_transaction_count} > 1) { $self->{private_bz_transaction_count}--; } elsif ($self->bz_in_transaction) { $self->commit(); $self->{private_bz_transaction_count} = 0; + Bugzilla::Mailer->send_staged_mail(); } else { ThrowCodeError('not_in_transaction'); } @@ -1251,11 +1256,9 @@ sub db_new { ShowErrorStatement => 1, HandleError => \&_handle_error, TaintIn => 1, - FetchHashKeyName => 'NAME', - # Note: NAME_lc causes crash on ActiveState Perl - # 5.8.4 (see Bug 253696) - # XXX - This will likely cause problems in DB - # back ends that twiddle column case (Oracle?) + # See https://rt.perl.org/rt3/Public/Bug/Display.html?id=30933 + # for the reason to use NAME instead of NAME_lc (bug 253696). + FetchHashKeyName => 'NAME', }; if ($override_attrs) { @@ -1365,14 +1368,19 @@ sub _bz_real_schema { my ($self) = @_; return $self->{private_real_schema} if exists $self->{private_real_schema}; - my ($data, $version) = $self->selectrow_array( - "SELECT schema_data, version FROM bz_schema"); + my $bz_schema; + unless ($bz_schema = Bugzilla->memcached->get({ key => 'bz_schema' })) { + $bz_schema = $self->selectrow_arrayref( + "SELECT schema_data, version FROM bz_schema" + ); + Bugzilla->memcached->set({ key => 'bz_schema', value => $bz_schema }); + } (die "_bz_real_schema tried to read the bz_schema table but it's empty!") - if !$data; + if !$bz_schema; - $self->{private_real_schema} = - $self->_bz_schema->deserialize_abstract($data, $version); + $self->{private_real_schema} = + $self->_bz_schema->deserialize_abstract($bz_schema->[0], $bz_schema->[1]); return $self->{private_real_schema}; } @@ -1414,6 +1422,8 @@ sub _bz_store_real_schema { $sth->bind_param(1, $store_me, $self->BLOB_TYPE); $sth->bind_param(2, $schema_version); $sth->execute(); + + Bugzilla->memcached->clear({ key => 'bz_schema' }); } # For bz_populate_enum_tables @@ -2685,3 +2695,63 @@ our check for implementation of C<new> by derived class useless. L<DBI> L<Bugzilla::Constants/DB_MODULE> + +=head1 B<Methods in need of POD> + +=over + +=item bz_add_fks + +=item bz_add_fk + +=item bz_drop_index_raw + +=item bz_table_info + +=item bz_add_index_raw + +=item bz_get_related_fks + +=item quote + +=item bz_drop_fk + +=item bz_drop_field_tables + +=item bz_drop_related_fks + +=item bz_table_columns + +=item bz_drop_foreign_keys + +=item bz_alter_column_raw + +=item bz_table_list_real + +=item bz_fk_info + +=item bz_setup_database + +=item bz_setup_foreign_keys + +=item bz_table_indexes + +=item bz_check_regexp + +=item bz_enum_initial_values + +=item bz_alter_fk + +=item bz_set_next_serial_value + +=item bz_table_list + +=item bz_table_columns_real + +=item bz_check_server_version + +=item bz_server_version + +=item bz_add_field_tables + +=back diff --git a/Bugzilla/DB/Mysql.pm b/Bugzilla/DB/Mysql.pm index dc93b7406..d0915f1e6 100644 --- a/Bugzilla/DB/Mysql.pm +++ b/Bugzilla/DB/Mysql.pm @@ -20,8 +20,12 @@ For interface details see L<Bugzilla::DB> and L<DBI>. =cut package Bugzilla::DB::Mysql; + +use 5.10.1; use strict; -use base qw(Bugzilla::DB); +use warnings; + +use parent qw(Bugzilla::DB); use Bugzilla::Constants; use Bugzilla::Install::Util qw(install_string); @@ -55,6 +59,18 @@ sub new { mysql_auto_reconnect => 1, ); + # MySQL SSL options + my ($ssl_ca_file, $ssl_ca_path, $ssl_cert, $ssl_key) = + @$params{qw(db_mysql_ssl_ca_file db_mysql_ssl_ca_path + db_mysql_ssl_client_cert db_mysql_ssl_client_key)}; + if ($ssl_ca_file || $ssl_ca_path || $ssl_cert || $ssl_key) { + $attrs{'mysql_ssl'} = 1; + $attrs{'mysql_ssl_ca_file'} = $ssl_ca_file if $ssl_ca_file; + $attrs{'mysql_ssl_ca_path'} = $ssl_ca_path if $ssl_ca_path; + $attrs{'mysql_ssl_client_cert'} = $ssl_cert if $ssl_cert; + $attrs{'mysql_ssl_client_key'} = $ssl_key if $ssl_key; + } + my $self = $class->db_new({ dsn => $dsn, user => $user, pass => $pass, attrs => \%attrs }); @@ -111,10 +127,13 @@ sub bz_last_key { } sub sql_group_concat { - my ($self, $column, $separator, $sort) = @_; + my ($self, $column, $separator, $sort, $order_by) = @_; $separator = $self->quote(', ') if !defined $separator; $sort = 1 if !defined $sort; - if ($sort) { + if ($order_by) { + $column .= " ORDER BY $order_by"; + } + elsif ($sort) { my $sort_order = $column; $sort_order =~ s/^DISTINCT\s+//i; $column = "$column ORDER BY $sort_order"; @@ -165,15 +184,19 @@ sub sql_fulltext_search { if ($text =~ /(?:^|\W)[+\-<>~"()]/ || $text =~ /[()"*](?:$|\W)/) { $mode = 'IN BOOLEAN MODE'; - # quote un-quoted compound words - my @words = 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/"/) { - $word = '"' . $word . '"'; + my @terms = split(quotemeta(FULLTEXT_OR), $text); + foreach my $term (@terms) { + # quote un-quoted compound words + my @words = quotewords('[\s()]+', 'delimiters', $term); + foreach my $word (@words) { + # match words that have non-word chars in the middle of them + if ($word =~ /\w\W+\w/ && $word !~ m/"/) { + $word = '"' . $word . '"'; + } } + $term = join('', @words); } - $text = join('', @words); + $text = join(FULLTEXT_OR, @terms); } # quote the text for use in the MATCH AGAINST expression @@ -1035,3 +1058,49 @@ sub _bz_build_schema_from_disk { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item sql_date_format + +=item bz_explain + +=item bz_last_key + +=item sql_position + +=item sql_fulltext_search + +=item sql_iposition + +=item bz_enum_initial_values + +=item sql_group_by + +=item sql_limit + +=item sql_not_regexp + +=item sql_string_concat + +=item sql_date_math + +=item sql_to_days + +=item bz_check_server_version + +=item sql_from_days + +=item sql_regexp + +=item sql_istring + +=item sql_group_concat + +=item bz_setup_database + +=item bz_db_is_utf8 + +=back diff --git a/Bugzilla/DB/Oracle.pm b/Bugzilla/DB/Oracle.pm index 6511242dc..7424019ac 100644 --- a/Bugzilla/DB/Oracle.pm +++ b/Bugzilla/DB/Oracle.pm @@ -20,8 +20,12 @@ For interface details see L<Bugzilla::DB> and L<DBI>. =cut package Bugzilla::DB::Oracle; + +use 5.10.1; use strict; -use base qw(Bugzilla::DB); +use warnings; + +use parent qw(Bugzilla::DB); use DBD::Oracle; use DBD::Oracle qw(:ora_types); @@ -41,8 +45,6 @@ use constant BLOB_TYPE => { ora_type => ORA_BLOB }; use constant MIN_LONG_READ_LEN => 32 * 1024; use constant FULLTEXT_OR => ' OR '; -our $fulltext_label = 0; - sub new { my ($class, $params) = @_; my ($user, $pass, $host, $dbname, $port) = @@ -161,10 +163,11 @@ sub sql_from_days{ sub sql_fulltext_search { my ($self, $column, $text) = @_; + state $label = 0; $text = $self->quote($text); trick_taint($text); - $fulltext_label++; - return "CONTAINS($column,$text,$fulltext_label) > 0", "SCORE($fulltext_label)"; + $label++; + return "CONTAINS($column,$text,$label) > 0", "SCORE($label)"; } sub sql_date_format { @@ -716,7 +719,12 @@ sub _get_create_trigger_ddl { ############################################################################ package Bugzilla::DB::Oracle::st; -use base qw(DBI::st); + +use 5.10.1; +use strict; +use warnings; + +use parent -norequire, qw(DBI::st); sub fetchrow_arrayref { my $self = shift; @@ -781,3 +789,69 @@ sub fetch { return $row; } 1; + +=head1 B<Methods in need of POD> + +=over + +=item adjust_statement + +=item bz_check_regexp + +=item bz_drop_table + +=item bz_explain + +=item bz_last_key + +=item bz_setup_database + +=item bz_table_columns_real + +=item bz_table_list_real + +=item do + +=item prepare + +=item prepare_cached + +=item quote_identifier + +=item selectall_arrayref + +=item selectall_hashref + +=item selectcol_arrayref + +=item selectrow_array + +=item selectrow_arrayref + +=item selectrow_hashref + +=item sql_date_format + +=item sql_date_math + +=item sql_from_days + +=item sql_fulltext_search + +=item sql_group_concat + +=item sql_in + +=item sql_limit + +=item sql_not_regexp + +=item sql_position + +=item sql_regexp + +=item sql_string_concat + +=item sql_to_days + +=back diff --git a/Bugzilla/DB/Pg.pm b/Bugzilla/DB/Pg.pm index cd5fed581..a950c575f 100644 --- a/Bugzilla/DB/Pg.pm +++ b/Bugzilla/DB/Pg.pm @@ -21,13 +21,16 @@ For interface details see L<Bugzilla::DB> and L<DBI>. package Bugzilla::DB::Pg; +use 5.10.1; use strict; +use warnings; use Bugzilla::Error; +use Bugzilla::Version; use DBD::Pg; # This module extends the DB interface via inheritance -use base qw(Bugzilla::DB); +use parent qw(Bugzilla::DB); use constant BLOB_TYPE => { pg_type => DBD::Pg::PG_BYTEA }; @@ -78,14 +81,37 @@ sub bz_last_key { } sub sql_group_concat { - my ($self, $text, $separator, $sort) = @_; + my ($self, $text, $separator, $sort, $order_by) = @_; $sort = 1 if !defined $sort; $separator = $self->quote(', ') if !defined $separator; - my $sql = "array_accum($text)"; - if ($sort) { - $sql = "array_sort($sql)"; + + # PostgreSQL 8.x doesn't support STRING_AGG + if (vers_cmp($self->bz_server_version, 9) < 0) { + my $sql = "ARRAY_ACCUM($text)"; + if ($sort) { + $sql = "ARRAY_SORT($sql)"; + } + return "ARRAY_TO_STRING($sql, $separator)"; + } + + if ($order_by && $text =~ /^DISTINCT\s*(.+)$/i) { + # Since Postgres (quite rightly) doesn't support "SELECT DISTINCT x + # ORDER BY y", we need to sort the list, and then get the unique + # values + return "ARRAY_TO_STRING(ANYARRAY_UNIQ(ARRAY_AGG($1 ORDER BY $order_by)), $separator)"; } - return "array_to_string($sql, $separator)"; + + # Determine the ORDER BY clause (if any) + if ($order_by) { + $order_by = " ORDER BY $order_by"; + } + elsif ($sort) { + # We don't include the DISTINCT keyword in an order by + $text =~ /^(?:DISTINCT\s*)?(.+)$/i; + $order_by = " ORDER BY $1"; + } + + return "STRING_AGG(${text}::text, $separator${order_by}::text)" } sub sql_istring { @@ -209,21 +235,25 @@ sub bz_setup_database { my $self = shift; $self->SUPER::bz_setup_database(@_); - # Custom Functions - my $function = 'array_accum'; - my $array_accum = $self->selectrow_array( - 'SELECT 1 FROM pg_proc WHERE proname = ?', undef, $function); - if (!$array_accum) { - print "Creating function $function...\n"; - $self->do("CREATE AGGREGATE array_accum ( - SFUNC = array_append, - BASETYPE = anyelement, - STYPE = anyarray, - INITCOND = '{}' - )"); - } + my ($has_plpgsql) = $self->selectrow_array("SELECT COUNT(*) FROM pg_language WHERE lanname = 'plpgsql'"); + $self->do('CREATE LANGUAGE plpgsql') unless $has_plpgsql; + + if (vers_cmp($self->bz_server_version, 9) < 0) { + # Custom Functions for Postgres 8 + my $function = 'array_accum'; + my $array_accum = $self->selectrow_array( + 'SELECT 1 FROM pg_proc WHERE proname = ?', undef, $function); + if (!$array_accum) { + print "Creating function $function...\n"; + $self->do("CREATE AGGREGATE array_accum ( + SFUNC = array_append, + BASETYPE = anyelement, + STYPE = anyarray, + INITCOND = '{}' + )"); + } - $self->do(<<'END'); + $self->do(<<'END'); CREATE OR REPLACE FUNCTION array_sort(ANYARRAY) RETURNS ANYARRAY LANGUAGE SQL IMMUTABLE STRICT @@ -236,6 +266,57 @@ SELECT ARRAY( ); $$; END + } + else { + # Custom functions for Postgres 9.0+ + + # -Copyright © 2013 Joshua D. Burns (JDBurnZ) and Message In Action LLC + # JDBurnZ: https://github.com/JDBurnZ + # Message In Action: https://www.messageinaction.com + # + #Permission is hereby granted, free of charge, to any person obtaining a copy of + #this software and associated documentation files (the "Software"), to deal in + #the Software without restriction, including without limitation the rights to + #use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + #the Software, and to permit persons to whom the Software is furnished to do so, + #subject to the following conditions: + # + #The above copyright notice and this permission notice shall be included in all + #copies or substantial portions of the Software. + # + #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + #IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + #FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + #COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + #IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + #CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + $self->do(q| + DROP FUNCTION IF EXISTS anyarray_uniq(anyarray); + CREATE OR REPLACE FUNCTION anyarray_uniq(with_array anyarray) + RETURNS anyarray AS $BODY$ + DECLARE + -- The variable used to track iteration over "with_array". + loop_offset integer; + + -- The array to be returned by this function. + return_array with_array%TYPE := '{}'; + BEGIN + IF with_array IS NULL THEN + return NULL; + END IF; + + -- Iterate over each element in "concat_array". + FOR loop_offset IN ARRAY_LOWER(with_array, 1)..ARRAY_UPPER(with_array, 1) LOOP + IF NOT with_array[loop_offset] = ANY(return_array) THEN + return_array = ARRAY_APPEND(return_array, with_array[loop_offset]); + END IF; + END LOOP; + + RETURN return_array; + END; + $BODY$ LANGUAGE plpgsql; + |); + } # PostgreSQL doesn't like having *any* index on the thetext # field, because it can't have index data longer than 2770 @@ -366,3 +447,43 @@ sub bz_table_list_real { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item sql_date_format + +=item bz_explain + +=item bz_sequence_exists + +=item bz_last_key + +=item sql_position + +=item sql_limit + +=item sql_not_regexp + +=item sql_string_concat + +=item sql_date_math + +=item sql_to_days + +=item bz_check_server_version + +=item sql_from_days + +=item bz_table_list_real + +=item sql_regexp + +=item sql_istring + +=item sql_group_concat + +=item bz_setup_database + +=back diff --git a/Bugzilla/DB/Schema.pm b/Bugzilla/DB/Schema.pm index 80c57a3df..d1c1dc7e9 100644 --- a/Bugzilla/DB/Schema.pm +++ b/Bugzilla/DB/Schema.pm @@ -15,7 +15,10 @@ package Bugzilla::DB::Schema; # ########################################################################### +use 5.10.1; use strict; +use warnings; + use Bugzilla::Error; use Bugzilla::Hook; use Bugzilla::Util; @@ -252,7 +255,7 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'profiles', COLUMN => 'userid'}}, version => {TYPE => 'varchar(64)', NOTNULL => 1}, - component_id => {TYPE => 'INT2', NOTNULL => 1, + component_id => {TYPE => 'INT3', NOTNULL => 1, REFERENCES => {TABLE => 'components', COLUMN => 'id'}}, resolution => {TYPE => 'varchar(64)', @@ -275,11 +278,8 @@ use constant ABSTRACT_SCHEMA => { remaining_time => {TYPE => 'decimal(7,2)', NOTNULL => 1, DEFAULT => '0'}, deadline => {TYPE => 'DATETIME'}, - alias => {TYPE => 'varchar(20)'}, ], INDEXES => [ - bugs_alias_idx => {FIELDS => ['alias'], - TYPE => 'UNIQUE'}, bugs_assigned_to_idx => ['assigned_to'], bugs_creation_ts_idx => ['creation_ts'], bugs_delta_ts_idx => ['delta_ts'], @@ -356,6 +356,21 @@ use constant ABSTRACT_SCHEMA => { ], }, + bugs_aliases => { + FIELDS => [ + alias => {TYPE => 'varchar(40)', NOTNULL => 1}, + bug_id => {TYPE => 'INT3', + REFERENCES => {TABLE => 'bugs', + COLUMN => 'bug_id', + DELETE => 'CASCADE'}}, + ], + INDEXES => [ + bugs_aliases_bug_id_idx => ['bug_id'], + bugs_aliases_alias_idx => {FIELDS => ['alias'], + TYPE => 'UNIQUE'}, + ], + }, + cc => { FIELDS => [ bug_id => {TYPE => 'INT3', NOTNULL => 1, @@ -404,6 +419,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, @@ -632,7 +695,7 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'products', COLUMN => 'id', DELETE => 'CASCADE'}}, - component_id => {TYPE => 'INT2', + component_id => {TYPE => 'INT3', REFERENCES => {TABLE => 'components', COLUMN => 'id', DELETE => 'CASCADE'}}, @@ -653,7 +716,7 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'products', COLUMN => 'id', DELETE => 'CASCADE'}}, - component_id => {TYPE => 'INT2', + component_id => {TYPE => 'INT3', REFERENCES => {TABLE => 'components', COLUMN => 'id', DELETE => 'CASCADE'}}, @@ -943,6 +1006,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, @@ -1050,7 +1130,7 @@ use constant ABSTRACT_SCHEMA => { REFERENCES => {TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE'}}, - component_id => {TYPE => 'INT2', NOTNULL => 1, + component_id => {TYPE => 'INT3', NOTNULL => 1, REFERENCES => {TABLE => 'components', COLUMN => 'id', DELETE => 'CASCADE'}}, @@ -1110,7 +1190,7 @@ use constant ABSTRACT_SCHEMA => { issuedate => {TYPE => 'DATETIME', NOTNULL => 1} , token => {TYPE => 'varchar(16)', NOTNULL => 1, PRIMARYKEY => 1}, - tokentype => {TYPE => 'varchar(8)', NOTNULL => 1} , + tokentype => {TYPE => 'varchar(16)', NOTNULL => 1} , eventdata => {TYPE => 'TINYTEXT'}, ], INDEXES => [ @@ -1328,7 +1408,7 @@ use constant ABSTRACT_SCHEMA => { components => { FIELDS => [ - id => {TYPE => 'SMALLSERIAL', NOTNULL => 1, + id => {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1}, name => {TYPE => 'varchar(64)', NOTNULL => 1}, product_id => {TYPE => 'INT2', NOTNULL => 1, @@ -1549,6 +1629,16 @@ use constant ABSTRACT_SCHEMA => { ], }, + # BUGMAIL + # ------- + + mail_staging => { + FIELDS => [ + id => {TYPE => 'INTSERIAL', PRIMARYKEY => 1, NOTNULL => 1}, + message => {TYPE => 'LONGBLOB', NOTNULL => 1}, + ], + }, + # THESCHWARTZ TABLES # ------------------ # Note: In the standard TheSchwartz schema, most integers are unsigned, @@ -1646,6 +1736,46 @@ use constant ABSTRACT_SCHEMA => { ], }, + bug_user_last_visit => { + FIELDS => [ + id => {TYPE => 'INTSERIAL', NOTNULL => 1, + PRIMARYKEY => 1}, + 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'}}, + last_visit_ts => {TYPE => 'DATETIME', NOTNULL => 1}, + ], + INDEXES => [ + bug_user_last_visit_idx => {FIELDS => ['user_id', 'bug_id'], + TYPE => 'UNIQUE'}, + bug_user_last_visit_last_visit_ts_idx => ['last_visit_ts'], + ], + }, + + user_api_keys => { + FIELDS => [ + id => {TYPE => 'INTSERIAL', NOTNULL => 1, + PRIMARYKEY => 1}, + user_id => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE'}}, + api_key => {TYPE => 'VARCHAR(40)', NOTNULL => 1}, + description => {TYPE => 'VARCHAR(255)'}, + revoked => {TYPE => 'BOOLEAN', NOTNULL => 1, + DEFAULT => 'FALSE'}, + last_used => {TYPE => 'DATETIME'}, + ], + INDEXES => [ + user_api_keys_api_key_idx => {FIELDS => ['api_key'], TYPE => 'UNIQUE'}, + user_api_keys_user_id_idx => ['user_id'], + ], + }, }; # Foreign Keys are added in Bugzilla::DB::bz_add_field_tables @@ -3011,3 +3141,19 @@ L<Bugzilla::DB> L<http://www.bugzilla.org/docs/developer.html#sql-schema> =cut + +=head1 B<Methods in need of POD> + +=over + +=item get_table_indexes_abstract + +=item get_create_database_sql + +=item get_add_fks_sql + +=item get_fk_ddl + +=item get_drop_fk_sql + +=back diff --git a/Bugzilla/DB/Schema/Mysql.pm b/Bugzilla/DB/Schema/Mysql.pm index 020549a89..0195fcb06 100644 --- a/Bugzilla/DB/Schema/Mysql.pm +++ b/Bugzilla/DB/Schema/Mysql.pm @@ -13,10 +13,13 @@ package Bugzilla::DB::Schema::Mysql; # ############################################################################### +use 5.10.1; use strict; +use warnings; + use Bugzilla::Error; -use base qw(Bugzilla::DB::Schema); +use parent qw(Bugzilla::DB::Schema); # This is for column_info_to_column, to know when a tinyint is a # boolean and when it's really a tinyint. This only has to be accurate @@ -104,7 +107,7 @@ sub _initialize { LONGBLOB => 'longblob', DATETIME => 'datetime', - + DATE => 'date', }; $self->_adjust_schema; @@ -381,3 +384,27 @@ sub get_rename_column_ddl { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item get_rename_column_ddl + +=item get_create_database_sql + +=item get_drop_index_ddl + +=item get_set_serial_sql + +=item get_rename_indexes_ddl + +=item get_drop_fk_sql + +=item MYISAM_TABLES + +=item column_info_to_column + +=item get_alter_column_ddl + +=back diff --git a/Bugzilla/DB/Schema/Oracle.pm b/Bugzilla/DB/Schema/Oracle.pm index a97929726..8fb5479b1 100644 --- a/Bugzilla/DB/Schema/Oracle.pm +++ b/Bugzilla/DB/Schema/Oracle.pm @@ -13,9 +13,11 @@ package Bugzilla::DB::Schema::Oracle; # ############################################################################### +use 5.10.1; use strict; +use warnings; -use base qw(Bugzilla::DB::Schema); +use parent qw(Bugzilla::DB::Schema); use Carp qw(confess); use Bugzilla::Util; @@ -55,7 +57,7 @@ sub _initialize { LONGBLOB => 'blob', DATETIME => 'date', - + DATE => 'date', }; $self->_adjust_schema; @@ -503,3 +505,33 @@ sub get_set_serial_sql { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item get_rename_column_ddl + +=item get_add_fks_sql + +=item get_drop_index_ddl + +=item get_rename_table_sql + +=item get_add_column_ddl + +=item get_set_serial_sql + +=item get_drop_column_ddl + +=item get_drop_table_ddl + +=item get_drop_fk_sql + +=item get_table_ddl + +=item get_alter_column_ddl + +=item get_fk_ddl + +=back diff --git a/Bugzilla/DB/Schema/Pg.pm b/Bugzilla/DB/Schema/Pg.pm index 3dc0fe85e..55a932272 100644 --- a/Bugzilla/DB/Schema/Pg.pm +++ b/Bugzilla/DB/Schema/Pg.pm @@ -13,8 +13,11 @@ package Bugzilla::DB::Schema::Pg; # ############################################################################### +use 5.10.1; use strict; -use base qw(Bugzilla::DB::Schema); +use warnings; + +use parent qw(Bugzilla::DB::Schema); use Storable qw(dclone); #------------------------------------------------------------------------------ @@ -64,7 +67,7 @@ sub _initialize { LONGBLOB => 'bytea', DATETIME => 'timestamp(0) without time zone', - + DATE => 'date', }; $self->_adjust_schema; @@ -186,3 +189,17 @@ sub _get_alter_type_sql { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item get_rename_column_ddl + +=item get_rename_table_sql + +=item get_create_database_sql + +=item get_set_serial_sql + +=back diff --git a/Bugzilla/DB/Schema/Sqlite.pm b/Bugzilla/DB/Schema/Sqlite.pm index 4cfc2b052..ccdbfd8aa 100644 --- a/Bugzilla/DB/Schema/Sqlite.pm +++ b/Bugzilla/DB/Schema/Sqlite.pm @@ -5,9 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; package Bugzilla::DB::Schema::Sqlite; -use base qw(Bugzilla::DB::Schema); + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::DB::Schema); use Bugzilla::Error; use Bugzilla::Util qw(generate_random_password); @@ -43,6 +47,7 @@ sub _initialize { LONGBLOB => 'blob', DATETIME => 'DATETIME', + DATE => 'DATETIME', }; $self->_adjust_schema; @@ -296,3 +301,25 @@ sub get_drop_fk_sql { 1; + +=head1 B<Methods in need of POD> + +=over + +=item get_rename_column_ddl + +=item get_add_fks_sql + +=item get_drop_fk_sql + +=item get_create_database_sql + +=item get_alter_column_ddl + +=item get_add_column_ddl + +=item get_type_ddl + +=item get_drop_column_ddl + +=back diff --git a/Bugzilla/DB/Sqlite.pm b/Bugzilla/DB/Sqlite.pm index 47cb0cd25..ddafc1696 100644 --- a/Bugzilla/DB/Sqlite.pm +++ b/Bugzilla/DB/Sqlite.pm @@ -5,9 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; package Bugzilla::DB::Sqlite; -use base qw(Bugzilla::DB); + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::DB); use Bugzilla::Constants; use Bugzilla::Error; @@ -296,3 +300,39 @@ SQLite-specific implementation. It is instantiated by the Bugzilla::DB module and should never be used directly. For interface details see L<Bugzilla::DB> and L<DBI>. + +=head1 B<Methods in need of POD> + +=over + +=item sql_date_format + +=item bz_explain + +=item sql_position + +=item sql_iposition + +=item sql_group_by + +=item sql_not_regexp + +=item sql_limit + +=item sql_date_math + +=item sql_to_days + +=item sql_from_days + +=item bz_table_list_real + +=item sql_regexp + +=item sql_group_concat + +=item sql_istring + +=item bz_setup_database + +=back diff --git a/Bugzilla/Error.pm b/Bugzilla/Error.pm index 32c7715b4..ef6320d15 100644 --- a/Bugzilla/Error.pm +++ b/Bugzilla/Error.pm @@ -7,8 +7,11 @@ package Bugzilla::Error; +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent qw(Exporter); @Bugzilla::Error::EXPORT = qw(ThrowCodeError ThrowTemplateError ThrowUserError); @@ -93,7 +96,7 @@ sub _throw_error { if (Bugzilla->error_mode == ERROR_MODE_WEBPAGE) { my $cgi = Bugzilla->cgi; - $cgi->close_standby_message('text/html', 'inline'); + $cgi->close_standby_message('text/html', 'inline', 'error', 'html'); print $message; print $cgi->multipart_final() if $cgi->{_multipart_in_progress}; } @@ -104,7 +107,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() }; @@ -121,13 +125,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 @@ -182,24 +193,18 @@ sub ThrowTemplateError { my $maintainer = Bugzilla->params->{'maintainer'}; my $error = html_quote($vars->{'template_error_msg'}); my $error2 = html_quote($template->error()); + my $url = html_quote(Bugzilla->cgi->self_url); + 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. </p> - <script type="text/javascript"> <!-- - document.write("<p>URL: " + - document.location.href.replace(/&/g,"&") - .replace(/</g,"<") - .replace(/>/g,">") + "</p>"); - // --> - </script> + <p>URL: $url</p> <p>Template->process() failed twice.<br> First error: $error<br> Second error: $error2</p> - </tt> END } exit; diff --git a/Bugzilla/Extension.pm b/Bugzilla/Extension.pm index 49422eca3..e24ceb9eb 100644 --- a/Bugzilla/Extension.pm +++ b/Bugzilla/Extension.pm @@ -6,7 +6,10 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Extension; + +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; use Bugzilla::Error; @@ -238,7 +241,7 @@ F<extensions/Foo.pm>: package Bugzilla::Extension::Foo use strict; - use base qw(Bugzilla::Extension); + use parent qw(Bugzilla::Extension); our $VERSION = '0.02'; use constant NAME => 'Foo'; @@ -648,6 +651,21 @@ So, for example, if you had a CSS file called F<style.css> and your extension was called F<Foo>, your file would go into F<extensions/Foo/web/style.css>. +=head2 Documenting Extensions + +Documentation goes in F<extensions/Foo/docs/en/rst/>, if it's in English, or +change "en" to something else if it's not. The user documentation index file +must be called index-user.rst; the admin documentation must be called +index-admin.rst. These will end up in the User Guide and the Administration +Guide respectively. Both documentation types are optional. You can use various +Sphinx constructs such as :toctree: or :include: to include further reST files +if you need more than one page of docs. + +When documenting extensions to the Bugzilla API, if your extension provides +them, the index file would be F<extensions/Foo/docs/en/rst/api/v1/index.rst>. +When and if your API has more than one version, increment the version number. +These docs will get included in the WebService API Reference. + =head2 Disabling Your Extension If you want your extension to be totally ignored by Bugzilla (it will @@ -807,3 +825,13 @@ package name of the loaded extension. Calls L</load> for every enabled extension installed into Bugzilla, and returns an arrayref of all the package names that were loaded. + +=head1 B<Methods in need of POD> + +=over + +=item modify_inc + +=item my_inc + +=back diff --git a/Bugzilla/Field.pm b/Bugzilla/Field.pm index 0c9da9b56..761f7b94e 100644 --- a/Bugzilla/Field.pm +++ b/Bugzilla/Field.pm @@ -57,9 +57,11 @@ in addition to what is documented here. package Bugzilla::Field; +use 5.10.1; use strict; +use warnings; -use base qw(Exporter Bugzilla::Object); +use parent qw(Exporter Bugzilla::Object); @Bugzilla::Field::EXPORT = qw(check_field get_field_id get_legal_field_values); use Bugzilla::Constants; @@ -73,6 +75,8 @@ use Scalar::Util qw(blessed); #### Initialization #### ############################### +use constant IS_CONFIG => 1; + use constant DB_TABLE => 'fielddefs'; use constant LIST_ORDER => 'sortkey, name'; @@ -150,7 +154,9 @@ 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' }, + FIELD_TYPE_INTEGER, { TYPE => 'INT4', NOTNULL => 1, DEFAULT => 0 }, }; # Field definitions for the fields that ship with Bugzilla. @@ -204,9 +210,9 @@ use constant DEFAULT_FIELDS => ( in_new_bugmail => 0, 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'}, @@ -220,7 +226,7 @@ use constant DEFAULT_FIELDS => ( {name => 'attachments.submitter', desc => 'Attachment creator'}, {name => 'target_milestone', desc => 'Target Milestone', - buglist => 1}, + in_new_bugmail => 1, buglist => 1}, {name => 'creation_ts', desc => 'Creation date', buglist => 1}, {name => 'delta_ts', desc => 'Last changed date', @@ -257,8 +263,11 @@ use constant DEFAULT_FIELDS => ( {name => "owner_idle_time", desc => "Time Since Assignee Touched"}, {name => 'see_also', desc => "See Also", type => FIELD_TYPE_BUG_URLS}, - {name => 'tag', desc => 'Tags', buglist => 1, + {name => 'tag', desc => 'Personal Tags', buglist => 1, type => FIELD_TYPE_KEYWORDS}, + {name => 'last_visit_ts', desc => 'Last Visit', buglist => 1, + type => FIELD_TYPE_DATETIME}, + {name => 'comment_tag', desc => 'Comment Tag'}, ); ################ @@ -354,9 +363,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}; @@ -957,7 +964,10 @@ sub remove_from_db { } else { $bugs_query = "SELECT COUNT(*) FROM bugs WHERE $name IS NOT NULL"; - if ($self->type != FIELD_TYPE_BUG_ID && $self->type != FIELD_TYPE_DATETIME) { + 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 @@ -1055,6 +1065,7 @@ sub create { $field->_update_visibility_values(); $dbh->bz_commit_transaction(); + Bugzilla->memcached->clear_config(); if ($field->custom) { my $name = $field->name; @@ -1077,6 +1088,9 @@ sub create { # Restore the original obsolete state of the custom field. $dbh->do('UPDATE fielddefs SET obsolete = 0 WHERE id = ?', undef, $field->id) unless $is_obsolete; + + Bugzilla->memcached->clear({ table => 'fielddefs', id => $field->id }); + Bugzilla->memcached->clear_config(); } return $field; @@ -1090,6 +1104,7 @@ sub update { $dbh->do("UPDATE " . $self->name . " SET visibility_value_id = NULL"); } $self->_update_visibility_values(); + Bugzilla->memcached->clear_config(); return $changes; } @@ -1327,7 +1342,7 @@ sub check_field { Description: Returns the ID of the specified field name and throws an error if this field does not exist. -Params: $name - a field name +Params: $fieldname - a field name Returns: the corresponding field ID or an error if the field name does not exist. @@ -1337,17 +1352,24 @@ Returns: the corresponding field ID or an error if the field name =cut sub get_field_id { - my ($name) = @_; - my $dbh = Bugzilla->dbh; + my $field = Bugzilla->fields({ by_name => 1 })->{$_[0]} + or ThrowCodeError('invalid_field_name', {field => $_[0]}); - trick_taint($name); - my $id = $dbh->selectrow_array('SELECT id FROM fielddefs - WHERE name = ?', undef, $name); - - ThrowCodeError('invalid_field_name', {field => $name}) unless $id; - return $id + return $field->id; } 1; __END__ + +=head1 B<Methods in need of POD> + +=over + +=item match + +=item set_is_numeric + +=item update + +=back diff --git a/Bugzilla/Field/Choice.pm b/Bugzilla/Field/Choice.pm index a292185c4..a66f69cee 100644 --- a/Bugzilla/Field/Choice.pm +++ b/Bugzilla/Field/Choice.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Field::Choice; -use base qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object); use Bugzilla::Config qw(SetParam write_params); use Bugzilla::Constants; @@ -23,6 +25,8 @@ use Scalar::Util qw(blessed); # Initialization # ################## +use constant IS_CONFIG => 1; + use constant DB_COLUMNS => qw( id value @@ -94,7 +98,7 @@ sub type { if (!defined *{"${package}::DB_TABLE"}) { eval <<EOC; package $package; - use base qw(Bugzilla::Field::Choice); + use parent qw(Bugzilla::Field::Choice); use constant DB_TABLE => '$field_name'; EOC } @@ -333,3 +337,23 @@ must call C<type> to get a class you can call methods on. This class implements mutators for all of the settable accessors in L<Bugzilla::Field::ChoiceInterface>. + +=head1 B<Methods in need of POD> + +=over + +=item create + +=item remove_from_db + +=item set_is_active + +=item set_sortkey + +=item set_name + +=item update + +=item set_visibility_value + +=back diff --git a/Bugzilla/Field/ChoiceInterface.pm b/Bugzilla/Field/ChoiceInterface.pm index b2f1bffd2..634d36ad1 100644 --- a/Bugzilla/Field/ChoiceInterface.pm +++ b/Bugzilla/Field/ChoiceInterface.pm @@ -6,7 +6,10 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Field::ChoiceInterface; + +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; use Bugzilla::Error; @@ -269,3 +272,13 @@ I<any> of the currently selected values are this value. Returns C<0> otherwise. =back + +=head1 B<Methods in need of POD> + +=over + +=item FIELD_NAME + +=item controlled_values_array + +=back diff --git a/Bugzilla/Flag.pm b/Bugzilla/Flag.pm index affeaee68..50474b885 100644 --- a/Bugzilla/Flag.pm +++ b/Bugzilla/Flag.pm @@ -5,10 +5,12 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Flag; +use 5.10.1; +use strict; +use warnings; + =head1 NAME Bugzilla::Flag - A module to deal with Bugzilla flag values. @@ -49,7 +51,7 @@ use Bugzilla::Mailer; use Bugzilla::Constants; use Bugzilla::Field; -use base qw(Bugzilla::Object Exporter); +use parent qw(Bugzilla::Object Exporter); @Bugzilla::Flag::EXPORT = qw(SKIP_REQUESTEE_ON_ERROR); ############################### @@ -180,22 +182,20 @@ 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'}); } 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 +205,15 @@ 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 }); } ################################ @@ -464,6 +463,7 @@ sub update { undef, ($timestamp, $self->id)); $self->{'modification_date'} = format_time($timestamp, '%Y.%m.%d %T', Bugzilla->local_timezone); + Bugzilla->memcached->clear({ table => 'flags', id => $self->id }); } return $changes; } @@ -610,6 +610,7 @@ sub force_retarget { if ($is_retargetted) { $dbh->do('UPDATE flags SET type_id = ? WHERE id = ?', undef, ($flag->type_id, $flag->id)); + Bugzilla->memcached->clear({ table => 'flags', id => $flag->id }); } else { # Track deleted attachment flags. @@ -670,10 +671,15 @@ sub _check_requestee { # Make sure the user didn't specify a requestee unless the flag # 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. - ThrowUserError('flag_requestee_disabled', { type => $self->type }) + # user can either remove them or leave them alone. + ThrowUserError('flag_type_requestee_disabled', { type => $self->type }) if !$self->type->is_requesteeble; + # 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 @@ -821,7 +827,7 @@ sub extract_flags_from_cgi { # Extract a list of existing flag IDs. my @flag_ids = map(/^flag-(\d+)$/ ? $1 : (), $cgi->param()); - return () if (!scalar(@flagtype_ids) && !scalar(@flag_ids)); + return ([], []) unless (scalar(@flagtype_ids) || scalar(@flag_ids)); my (@new_flags, @flags); foreach my $flag_id (@flag_ids) { @@ -927,6 +933,117 @@ sub extract_flags_from_cgi { =over +=item C<multi_extract_flags_from_cgi($bug, $hr_vars)> + +Checks whether or not there are new flags to create and returns an +array of hashes. This array is then passed to Flag::create(). This differs +from the previous sub-routine as it is called for changing multiple bugs + +=back + +=cut + +sub multi_extract_flags_from_cgi { + my ($class, $bug, $vars, $skip) = @_; + my $cgi = Bugzilla->cgi; + + my $match_status = Bugzilla::User::match_field({ + '^requestee(_type)?-(\d+)$' => { 'type' => 'multi' }, + }, undef, $skip); + + $vars->{'match_field'} = 'requestee'; + if ($match_status == USER_MATCH_FAILED) { + $vars->{'message'} = 'user_match_failed'; + } + elsif ($match_status == USER_MATCH_MULTIPLE) { + $vars->{'message'} = 'user_match_multiple'; + } + + # Extract a list of flag type IDs from field names. + my @flagtype_ids = map(/^flag_type-(\d+)$/ ? $1 : (), $cgi->param()); + + my (@new_flags, @flags); + + # Get a list of active flag types available for this product/component. + my $flag_types = Bugzilla::FlagType::match( + { 'product_id' => $bug->{'product_id'}, + 'component_id' => $bug->{'component_id'}, + 'is_active' => 1 }); + + foreach my $flagtype_id (@flagtype_ids) { + # Checks if there are unexpected flags for the product/component. + if (!scalar(grep { $_->id == $flagtype_id } @$flag_types)) { + $vars->{'message'} = 'unexpected_flag_types'; + last; + } + } + + foreach my $flag_type (@$flag_types) { + my $type_id = $flag_type->id; + + # Bug flags are only valid for bugs + next unless ($flag_type->target_type eq 'bug'); + + # We are only interested in flags the user tries to create. + next unless scalar(grep { $_ == $type_id } @flagtype_ids); + + # Get the flags of this type already set for this bug. + my $current_flags = $class->match( + { 'type_id' => $type_id, + 'target_type' => 'bug', + 'bug_id' => $bug->bug_id }); + + # We will update existing flags (instead of creating new ones) + # if the flag exists and the user has not chosen the 'always add' + # option + my $update = scalar(@$current_flags) && ! $cgi->param("flags_add-$type_id"); + + my $status = $cgi->param("flag_type-$type_id"); + trick_taint($status); + + my @logins = $cgi->param("requestee_type-$type_id"); + if ($status eq "?" && scalar(@logins)) { + foreach my $login (@logins) { + if ($update) { + foreach my $current_flag (@$current_flags) { + push (@flags, { id => $current_flag->id, + status => $status, + requestee => $login, + skip_roe => $skip }); + } + } + else { + push (@new_flags, { type_id => $type_id, + status => $status, + requestee => $login, + skip_roe => $skip }); + } + + last unless $flag_type->is_multiplicable; + } + } + else { + if ($update) { + foreach my $current_flag (@$current_flags) { + push (@flags, { id => $current_flag->id, + status => $status }); + } + } + else { + push (@new_flags, { type_id => $type_id, + status => $status }); + } + } + } + + # Return the list of flags to update and/or to create. + return (\@flags, \@new_flags); +} + +=pod + +=over + =item C<notify($flag, $old_flag, $object, $timestamp)> Sends an email notification about a flag being created, fulfilled @@ -1087,3 +1204,29 @@ sub _flag_types { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item update_activity + +=item setter_id + +=item bug + +=item requestee_id + +=item DB_COLUMNS + +=item set_flag + +=item type_id + +=item snapshot + +=item update_flags + +=item update + +=back diff --git a/Bugzilla/FlagType.pm b/Bugzilla/FlagType.pm index 9e7ab09de..72b3f64c1 100644 --- a/Bugzilla/FlagType.pm +++ b/Bugzilla/FlagType.pm @@ -5,10 +5,12 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::FlagType; +use 5.10.1; +use strict; +use warnings; + =head1 NAME Bugzilla::FlagType - A module to deal with Bugzilla flag types. @@ -41,7 +43,7 @@ use Bugzilla::Group; use Email::Address; use List::MoreUtils qw(uniq); -use base qw(Bugzilla::Object); +use parent qw(Bugzilla::Object); ############################### #### Initialization #### @@ -185,8 +187,16 @@ sub update { # Silently remove requestees from flags which are no longer # specifically requestable. if (!$self->is_requesteeble) { - $dbh->do('UPDATE flags SET requestee_id = NULL WHERE type_id = ?', - undef, $self->id); + my $ids = $dbh->selectcol_arrayref( + 'SELECT id FROM flags WHERE type_id = ? AND requestee_id IS NOT NULL', + undef, $self->id); + + if (@$ids) { + $dbh->do('UPDATE flags SET requestee_id = NULL WHERE ' . $dbh->sql_in('id', $ids)); + foreach my $id (@$ids) { + Bugzilla->memcached->clear({ table => 'flags', id => $id }); + } + } } $dbh->bz_commit_transaction(); @@ -650,9 +660,19 @@ sub sqlify_criteria { my @criteria = ("1=1"); if ($criteria->{name}) { - my $name = $dbh->quote($criteria->{name}); - trick_taint($name); # Detaint data as we have quoted it. - push(@criteria, "flagtypes.name = $name"); + if (ref($criteria->{name}) eq 'ARRAY') { + my @names = map { $dbh->quote($_) } @{$criteria->{name}}; + # Detaint data as we have quoted it. + foreach my $name (@names) { + trick_taint($name); + } + push @criteria, $dbh->sql_in('flagtypes.name', \@names); + } + else { + my $name = $dbh->quote($criteria->{name}); + trick_taint($name); # Detaint data as we have quoted it. + push(@criteria, "flagtypes.name = $name"); + } } if ($criteria->{target_type}) { # The target type is stored in the database as a one-character string @@ -716,3 +736,43 @@ sub sqlify_criteria { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item exclusions_as_hash + +=item request_group_id + +=item set_is_active + +=item set_is_multiplicable + +=item inclusions_as_hash + +=item set_sortkey + +=item grant_group_id + +=item set_cc_list + +=item set_request_group + +=item set_name + +=item set_is_specifically_requestable + +=item set_grant_group + +=item create + +=item set_clusions + +=item set_description + +=item set_is_requestable + +=item update + +=back diff --git a/Bugzilla/Group.pm b/Bugzilla/Group.pm index 5404dec7e..07b78e366 100644 --- a/Bugzilla/Group.pm +++ b/Bugzilla/Group.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Group; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::Constants; use Bugzilla::Util; @@ -20,6 +22,8 @@ use Bugzilla::Config qw(:admin); ##### Module Initialization ### ############################### +use constant IS_CONFIG => 1; + use constant DB_COLUMNS => qw( groups.id groups.name @@ -52,8 +56,10 @@ use constant UPDATE_COLUMNS => qw( ); # Parameters that are lists of groups. -use constant GROUP_PARAMS => qw(chartgroup insidergroup timetrackinggroup - querysharegroup debug_group); +use constant GROUP_PARAMS => qw( + chartgroup comment_taggers_group debug_group insidergroup + querysharegroup timetrackinggroup +); ############################### #### Accessors ###### @@ -92,7 +98,8 @@ sub members_non_inherited { sub _get_members { my ($self, $grant_type) = @_; my $dbh = Bugzilla->dbh; - my $grant_clause = $grant_type ? "AND grant_type = $grant_type" : ""; + my $grant_clause = defined($grant_type) ? "AND grant_type = $grant_type" + : ""; my $user_ids = $dbh->selectcol_arrayref( "SELECT DISTINCT user_id FROM user_group_map @@ -213,6 +220,7 @@ sub update { Bugzilla::Hook::process('group_end_of_update', { group => $self, changes => $changes }); $dbh->bz_commit_transaction(); + Bugzilla->memcached->clear_config(); return $changes; } @@ -647,3 +655,47 @@ user_group_map for any user with DIRECT or REGEXP membership IN() the list of groups returned. =back + +=head1 B<Methods in need of POD> + +=over + +=item icon_url + +=item set_name + +=item bugs + +=item granted_by_direct + +=item set_user_regexp + +=item flag_types + +=item products + +=item set_icon_url + +=item set_description + +=item set_is_active + +=item user_regexp + +=item members_direct + +=item is_bug_group + +=item grant_direct + +=item description + +=item is_active + +=item remove_from_db + +=item is_active_bug_group + +=item update + +=back diff --git a/Bugzilla/Hook.pm b/Bugzilla/Hook.pm index b75b329d5..d6ba5e1d0 100644 --- a/Bugzilla/Hook.pm +++ b/Bugzilla/Hook.pm @@ -6,7 +6,10 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Hook; + +use 5.10.1; use strict; +use warnings; sub process { my ($name, $args) = @_; @@ -585,7 +588,7 @@ about, and the value should always be C<1>. The "relationships" are described by the various C<REL_> constants in L<Bugzilla::Constants>. Here's an example of adding userid C<123> to the recipient list -as though he were on the CC list: +as though they were on the CC list: $recipients->{123}->{+REL_CC} = 1 @@ -639,6 +642,33 @@ spaces. =back +=head2 cgi_headers + +This allows you to modify the HTTP headers sent out on every Bugzilla +response. + +Params: + +=over + +=item C<headers> + +A hashref, where the keys are header names and the values are header +values. Keys need to be lower-case, and begin with a "-". If you use +the "_" character it will be converted to "-", and the library will +also fix the casing to Camel-Case. + +You can delete (some) headers that Bugzilla adds by deleting entries +from the hash. + +=item C<cgi> + +The CGI object, which may tell you useful things about the response on +which to base a decision of whether or not to add a header. + +=back + + =head2 config_add_panels If you want to add new panels to the Parameters administrative interface, @@ -1003,9 +1033,6 @@ Params: =item C<email> - The C<Email::MIME> object that's about to be sent. -=item C<mailer_args> - An arrayref that's passed as C<mailer_args> to -L<Email::Send/new>. - =back =head2 object_before_create @@ -1499,6 +1526,24 @@ name), you can get it from here. =back +=head2 user_check_account_creation + +This hook permits you to do extra checks before the creation of a new user +account. This hook is called after email address validation has been done. +Note that this hook can also access the IP address of the requester thanks +to the C<remote_ip()> subroutine exported by C<Bugzilla::Util>. + +Params: + +=over + +=item C<login> + +The login of the new account. This is usually an email address, unless the +C<emailsuffix> parameter is not empty. + +=back + =head2 user_preferences This hook allows you to add additional panels to the User Preferences page, @@ -1594,6 +1639,109 @@ See L<Bugzilla::WebService::Constants/WS_ERROR_CODE> for an example. =back +=head2 webservice_fix_credentials + +This hook allows for altering the credential parameters provided by the client +before authentication actually occurs. For example, this can be used to allow mapping +of custom parameters to the standard Bugzilla_login and Bugzilla_password parameters. + +Params: + +=over + +=item C<params> + +A hash ref containing the parameters passed into the webservice after +they have been obtained from the URL or body of the request. + +=back + +=head2 webservice_rest_request + +This hook allows for altering any of the parameters provided by the client +after authentication has occured. You are able to change things like renaming +of keys, removing values, or adding additional information. + +Params: + +=over + +=item C<params> + +A hash ref containing the parameters passed into the webservice after +they have been obtained from the URL or body of the request. + +=item C<rpc> + +The current JSONRPC, XMLRPC, or REST object. + +=back + +=head2 webservice_rest_resources + +This hook allows for altering of the REST resources data allowing you to +add additional paths to perform additional operations or to override the +resources already provided by the webservice modules. + +Params: + +=over + +=item C<resources> + +A hash returned from each module loaded that is used to determine +which code handler to use based on a regex match of the CGI path. + +=item C<rpc> + +The current JSONRPC, XMLRPC, or REST object. + +=back + +=head2 webservice_rest_response + +This hook allows for altering the result data or response object +that is being returned by the current REST webservice call. + +Params: + +=over + +=item C<response> + +The HTTP response object generated by JSON-RPC library. You can use this +to add headers, etc. + +=item C<result> + +A reference to a hash that contains the result data. + +=item C<rpc> + +The current JSONRPC, XMLRPC, or REST object. + +=back + +=head2 webservice_status_code_map + +This hook allows an extension to change the status codes returned by +specific webservice errors. The valid internal error codes that Bugzilla +generates, and the status codes they map to by default, are defined in the +C<WS_ERROR_CODE> constant in C<Bugzilla::WebService::Constants>. When +remapping an error, you may wish to use an existing status code constant. +Such constants are also in C<Bugzilla::WebService::Constants> and start +with C<STATUS_*> such as C<STATUS_BAD_REQUEST>. + +Params: + +=over + +=item C<status_code_map> + +A hash reference containing the current status code mapping. + +=back + =head1 SEE ALSO L<Bugzilla::Extension> diff --git a/Bugzilla/Install.pm b/Bugzilla/Install.pm index 4954dbfd0..07bc9d6c3 100644 --- a/Bugzilla/Install.pm +++ b/Bugzilla/Install.pm @@ -15,7 +15,9 @@ package Bugzilla::Install; # make those assumptions, then it should go into one of the # packages under the Bugzilla::Install namespace. +use 5.10.1; use strict; +use warnings; use Bugzilla::Component; use Bugzilla::Config qw(:admin); @@ -25,7 +27,7 @@ use Bugzilla::Group; use Bugzilla::Product; use Bugzilla::User; use Bugzilla::User::Setting; -use Bugzilla::Util qw(get_text say); +use Bugzilla::Util qw(get_text); use Bugzilla::Version; use constant STATUS_WORKFLOW => ( @@ -86,6 +88,8 @@ sub SETTINGS { requestee_cc => { options => ['on', 'off'], default => 'on' }, # 2012-04-30 glob@mozilla.com -- Bug 663747 bugmail_new_prefix => { options => ['on', 'off'], default => 'on' }, + # 2013-07-26 joshi_sunil@in.com -- Bug 669535 + possible_duplicates => { options => ['on', 'off'], default => 'on' }, } }; @@ -492,3 +496,19 @@ Params: none Returns: nothing =back + +=head1 B<Methods in need of POD> + +=over + +=item update_system_groups + +=item reset_password + +=item make_admin + +=item create_admin + +=item init_workflow + +=back diff --git a/Bugzilla/Install/CPAN.pm b/Bugzilla/Install/CPAN.pm index 8a880df80..094784e1a 100644 --- a/Bugzilla/Install/CPAN.pm +++ b/Bugzilla/Install/CPAN.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Install::CPAN; + +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent qw(Exporter); our @EXPORT = qw( BZ_LIB @@ -161,20 +165,9 @@ sub install_module { if (!$module) { die install_string('no_such_module', { module => $name }) . "\n"; } - my $version = $module->cpan_version; - my $module_name = $name; - - if ($name eq 'LWP::UserAgent' && $^V lt v5.8.8) { - # LWP 6.x requires Perl 5.8.8 or newer. - # As PAUSE only indexes the very last version of each module, - # we have to specify the path to the tarball ourselves. - $name = 'GAAS/libwww-perl-5.837.tar.gz'; - # This tarball contains LWP::UserAgent 5.835. - $version = '5.835'; - } print install_string('install_module', - { module => $module_name, version => $version }) . "\n"; + { module => $name, version => $module->cpan_version }) . "\n"; if ($test) { CPAN::Shell->force('install', $name); @@ -336,3 +329,11 @@ Note that calling this function prints a B<lot> of information to STDOUT and STDERR. =back + +=head1 B<Methods in need of POD> + +=over + +=item check_cpan_requirements + +=back diff --git a/Bugzilla/Install/DB.pm b/Bugzilla/Install/DB.pm index 023ef7523..96f14ec0f 100644 --- a/Bugzilla/Install/DB.pm +++ b/Bugzilla/Install/DB.pm @@ -10,7 +10,9 @@ package Bugzilla::Install::DB; # NOTE: This package may "use" any modules that it likes, # localconfig is available, and params are up to date. +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; use Bugzilla::Hook; @@ -23,6 +25,7 @@ use Bugzilla::Field; use Date::Parse; use Date::Format; +use Digest; use IO::File; use List::MoreUtils qw(uniq); use URI; @@ -270,10 +273,6 @@ sub update_table_definitions { $dbh->bz_add_column('attachments', 'isprivate', {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE'}); - $dbh->bz_add_column("bugs", "alias", {TYPE => "varchar(20)"}); - $dbh->bz_add_index('bugs', 'bugs_alias_idx', - {TYPE => 'UNIQUE', FIELDS => [qw(alias)]}); - _move_quips_into_db(); $dbh->bz_drop_column("namedqueries", "watchfordiffs"); @@ -700,12 +699,36 @@ sub update_table_definitions { # 2012-08-01 koosha.khajeh@gmail.com - Bug 187753 _shorten_long_quips(); - # 2012-12-23 LpSolit@gmail.com - Bug 824361 + # 2012-12-29 reed@reedloden.com - Bug 785283 + _add_password_salt_separator(); + + # 2013-01-02 LpSolit@gmail.com - Bug 824361 _fix_longdescs_indexes(); # 2013-02-04 dkl@mozilla.com - Bug 824346 _fix_flagclusions_indexes(); + # 2013-08-26 sgreen@redhat.com - Bug 903895 + _fix_components_primary_key(); + + # 2014-06-09 dylan@mozilla.com - Bug 1022923 + $dbh->bz_add_index('bug_user_last_visit', + 'bug_user_last_visit_last_visit_ts_idx', + ['last_visit_ts']); + + # 2014-07-14 sgreen@redhat.com - Bug 726696 + $dbh->bz_alter_column('tokens', 'tokentype', + {TYPE => 'varchar(16)', NOTNULL => 1}); + + # 2014-07-27 LpSolit@gmail.com - Bug 1044561 + _fix_user_api_keys_indexes(); + + # 2014-08-11 sgreen@redhat.com - Bug 1012506 + _update_alias(); + + # 2014-11-10 dkl@mozilla.com - Bug 1093928 + $dbh->bz_drop_column('longdescs', 'is_markdown'); + ################################################################ # New --TABLE-- changes should go *** A B O V E *** this point # ################################################################ @@ -863,8 +886,8 @@ sub _populate_longdescs { if (!$who) { # This username doesn't exist. Maybe someone - # renamed him or something. Invent a new profile - # entry disabled, just to represent him. + # renamed them or something. Invent a new profile + # entry disabled, just to represent them. $dbh->do("INSERT INTO profiles (login_name, cryptpassword, disabledtext) VALUES (?,?,?)", undef, $name, '*', @@ -1424,9 +1447,9 @@ sub _use_ids_for_products_and_components { print "Updating the database to use component IDs.\n"; $dbh->bz_add_column("components", "id", - {TYPE => 'SMALLSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); + {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); $dbh->bz_add_column("bugs", "component_id", - {TYPE => 'INT2', NOTNULL => 1}, 0); + {TYPE => 'INT3', NOTNULL => 1}, 0); my %components; $sth = $dbh->prepare("SELECT id, value, product_id FROM components"); @@ -2537,7 +2560,7 @@ sub _fix_whine_queries_title_and_op_sys_value { undef, "Other", "other"); if (Bugzilla->params->{'defaultopsys'} eq 'other') { # We can't actually fix the param here, because WriteParams() will - # make $datadir/params unwriteable to the webservergroup. + # make $datadir/params.json unwriteable to the webservergroup. # It's too much of an ugly hack to copy the permission-fixing code # down to here. (It would create more potential future bugs than # it would solve problems.) @@ -3785,6 +3808,39 @@ sub _shorten_long_quips { $dbh->bz_alter_column('quips', 'quip', { TYPE => 'varchar(512)', NOTNULL => 1}); } +sub _add_password_salt_separator { + my $dbh = Bugzilla->dbh; + + $dbh->bz_start_transaction(); + + my $profiles = $dbh->selectall_arrayref("SELECT userid, cryptpassword FROM profiles WHERE (" + . $dbh->sql_regexp("cryptpassword", "'^[^,]+{'") . ")"); + + if (@$profiles) { + say "Adding salt separator to password hashes..."; + + my $query = $dbh->prepare("UPDATE profiles SET cryptpassword = ? WHERE userid = ?"); + my %algo_sizes; + + foreach my $profile (@$profiles) { + my ($userid, $hash) = @$profile; + my ($algorithm) = $hash =~ /{([^}]+)}$/; + + $algo_sizes{$algorithm} ||= length(Digest->new($algorithm)->b64digest); + + # Calculate the salt length by taking the stored hash and + # subtracting the combined lengths of the hash size, the + # algorithm name, and 2 for the {} surrounding the name. + my $not_salt_len = $algo_sizes{$algorithm} + length($algorithm) + 2; + my $salt_len = length($hash) - $not_salt_len; + + substr($hash, $salt_len, 0, ','); + $query->execute($hash, $userid); + } + } + $dbh->bz_commit_transaction(); +} + sub _fix_flagclusions_indexes { my $dbh = Bugzilla->dbh; foreach my $table ('flaginclusions', 'flagexclusions') { @@ -3814,6 +3870,50 @@ sub _fix_flagclusions_indexes { } } +sub _fix_components_primary_key { + my $dbh = Bugzilla->dbh; + if ($dbh->bz_column_info('components', 'id')->{TYPE} ne 'MEDIUMSERIAL') { + $dbh->bz_drop_related_fks('components', 'id'); + $dbh->bz_alter_column("components", "id", + {TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1}); + $dbh->bz_alter_column("flaginclusions", "component_id", + {TYPE => 'INT3'}); + $dbh->bz_alter_column("flagexclusions", "component_id", + {TYPE => 'INT3'}); + $dbh->bz_alter_column("bugs", "component_id", + {TYPE => 'INT3', NOTNULL => 1}); + $dbh->bz_alter_column("component_cc", "component_id", + {TYPE => 'INT3', NOTNULL => 1}); + } +} + +sub _fix_user_api_keys_indexes { + my $dbh = Bugzilla->dbh; + + if ($dbh->bz_index_info('user_api_keys', 'user_api_keys_key')) { + $dbh->bz_drop_index('user_api_keys', 'user_api_keys_key'); + $dbh->bz_add_index('user_api_keys', 'user_api_keys_api_key_idx', + { FIELDS => ['api_key'], TYPE => 'UNIQUE' }); + } + if ($dbh->bz_index_info('user_api_keys', 'user_api_keys_user_id')) { + $dbh->bz_drop_index('user_api_keys', 'user_api_keys_user_id'); + $dbh->bz_add_index('user_api_keys', 'user_api_keys_user_id_idx', ['user_id']); + } +} + +sub _update_alias { + my $dbh = Bugzilla->dbh; + return unless $dbh->bz_column_info('bugs', 'alias'); + + # We need to move the aliases from the bugs table to the bugs_aliases table + $dbh->do(q{ + INSERT INTO bugs_aliases (bug_id, alias) + SELECT bug_id, alias FROM bugs WHERE alias IS NOT NULL + }); + + $dbh->bz_drop_column('bugs', 'alias'); +} + 1; __END__ diff --git a/Bugzilla/Install/Filesystem.pm b/Bugzilla/Install/Filesystem.pm index 457f66f9a..cf0a5b0ca 100644 --- a/Bugzilla/Install/Filesystem.pm +++ b/Bugzilla/Install/Filesystem.pm @@ -15,7 +15,9 @@ package Bugzilla::Install::Filesystem; # * Files do not have the correct permissions. # * The database does not exist. +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; use Bugzilla::Error; @@ -28,10 +30,12 @@ use File::Find; use File::Path; use File::Basename; use File::Copy qw(move); +use File::Spec; +use File::Slurp; use IO::File; use POSIX (); -use base qw(Exporter); +use parent qw(Exporter); our @EXPORT = qw( update_filesystem create_htaccess @@ -57,7 +61,7 @@ use constant HT_DEFAULT_DENY => <<EOT; </IfVersion> </IfModule> <IfModule !mod_version.c> - Deny from all + Deny from all </IfModule> EOT @@ -130,6 +134,7 @@ sub FILESYSTEM { my $localconfig = bz_locations()->{'localconfig'}; my $template_cache = bz_locations()->{'template_cache'}; my $graphsdir = bz_locations()->{'graphsdir'}; + my $assetsdir = bz_locations()->{'assetsdir'}; # We want to set the permissions the same for all localconfig files # across all PROJECTs, so we do something special with $localconfig, @@ -165,6 +170,7 @@ sub FILESYSTEM { 'jobqueue.pl' => { perms => OWNER_EXECUTE }, 'migrate.pl' => { perms => OWNER_EXECUTE }, 'install-module.pl' => { perms => OWNER_EXECUTE }, + 'clean-bug-user-last-visit.pl' => { perms => WS_EXECUTE }, 'Bugzilla.pm' => { perms => CGI_READ }, "$localconfig*" => { perms => CGI_READ }, @@ -181,7 +187,7 @@ sub FILESYSTEM { 'docs/style.css' => { perms => WS_SERVE }, 'docs/*/rel_notes.txt' => { perms => WS_SERVE }, 'docs/*/README.docs' => { perms => OWNER_WRITE }, - "$datadir/params" => { perms => CGI_WRITE }, + "$datadir/params.json" => { perms => CGI_WRITE }, "$datadir/old-params.txt" => { perms => OWNER_WRITE }, "$extensionsdir/create.pl" => { perms => OWNER_EXECUTE }, "$extensionsdir/*/*.pl" => { perms => WS_EXECUTE }, @@ -211,6 +217,8 @@ sub FILESYSTEM { dirs => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE }, "$datadir/db" => { files => CGI_WRITE, dirs => DIR_CGI_WRITE }, + $assetsdir => { files => WS_SERVE, + dirs => DIR_CGI_OVERWRITE | DIR_ALSO_WS_SERVE }, # Readable directories "$datadir/mining" => { files => CGI_READ, @@ -270,7 +278,8 @@ sub FILESYSTEM { # The name of each directory that we should actually *create*, # pointing at its default permissions. my %create_dirs = ( - # This is DIR_ALSO_WS_SERVE because it contains $webdotdir. + # This is DIR_ALSO_WS_SERVE because it contains $webdotdir and + # $assetsdir. $datadir => DIR_CGI_OVERWRITE | DIR_ALSO_WS_SERVE, # Directories that are read-only for cgi scripts "$datadir/mining" => DIR_CGI_READ, @@ -281,6 +290,7 @@ sub FILESYSTEM { $attachdir => DIR_CGI_WRITE, $graphsdir => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE, $webdotdir => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE, + $assetsdir => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE, # Directories that contain content served directly by the web server. "$skinsdir/custom" => DIR_WS_SERVE, "$skinsdir/contrib" => DIR_WS_SERVE, @@ -409,8 +419,8 @@ EOT </IfModule> </FilesMatch> -# Allow access to .png files created by a local copy of 'dot' -<FilesMatch \\.png\$> + # Allow access to .png files created by a local copy of 'dot' + <FilesMatch \\.png\$> <IfModule mod_version.c> <IfVersion < 2.4> Allow from all @@ -448,6 +458,48 @@ EOT </IfModule> EOT }, + + "$assetsdir/.htaccess" => { perms => WS_SERVE, contents => <<EOT +# Allow access to .css files +<FilesMatch \\.(css|js)\$> + <IfModule mod_version.c> + <IfVersion < 2.4> + Allow from all + </IfVersion> + <IfVersion >= 2.4> + <IfModule mod_perl.c> + Allow from all + </IfModule> + <IfModule !mod_perl.c> + Require all granted + </IfModule> + </IfVersion> + </IfModule> + <IfModule !mod_version.c> + Allow from all + </IfModule> +</FilesMatch> + +# And no directory listings, either. +<IfModule mod_version.c> + <IfVersion < 2.4> + Deny from all + </IfVersion> + <IfVersion >= 2.4> + <IfModule mod_perl.c> + Deny from all + </IfModule> + <IfModule !mod_perl.c> + Require all denied + </IfModule> + </IfVersion> +</IfModule> +<IfModule !mod_version.c> + Deny from all +</IfModule> +EOT + }, + ); Bugzilla::Hook::process('install_filesystem', { @@ -482,6 +534,7 @@ sub update_filesystem { my $datadir = bz_locations->{'datadir'}; my $graphsdir = bz_locations->{'graphsdir'}; + my $assetsdir = bz_locations->{'assetsdir'}; # If the graphs/ directory doesn't exist, we're upgrading from # a version old enough that we need to update the $datadir/mining # format. @@ -489,6 +542,13 @@ sub update_filesystem { _update_old_charts($datadir); } + # If there is a file named '-All-' in $datadir/mining, then we're still + # having mining files named by product name, and we need to convert them to + # files named by product ID. + if (-e File::Spec->catfile($datadir, 'mining', '-All-')) { + _update_old_mining_filenames(File::Spec->catdir($datadir, 'mining')); + } + # By sorting the dirs, we assure that shorter-named directories # (meaning parent directories) are always created before their # child directories. @@ -515,6 +575,13 @@ sub update_filesystem { _rename_file($oldparamsfile, "$datadir/$oldparamsfile"); } + # Remove old assets htaccess file to force recreation with correct values. + if (-e "$assetsdir/.htaccess") { + if (read_file("$assetsdir/.htaccess") =~ /<FilesMatch \\\.css\$>/) { + unlink("$assetsdir/.htaccess"); + } + } + _create_files(%files); if ($params->{index_html}) { _create_files(%{$fs->{index_html}}); @@ -558,6 +625,7 @@ EOT _remove_empty_css_files(); _convert_single_file_skins(); + _remove_dynamic_assets(); } sub _remove_empty_css_files { @@ -602,6 +670,27 @@ sub _convert_single_file_skins { } } +# delete all automatically generated css/js files to force recreation at the +# next request. +sub _remove_dynamic_assets { + my @files = ( + glob(bz_locations()->{assetsdir} . '/*.css'), + glob(bz_locations()->{assetsdir} . '/*.js'), + ); + foreach my $file (@files) { + unlink($file); + } + + # remove old skins/assets directory + my $old_path = bz_locations()->{skinsdir} . '/assets'; + if (-d $old_path) { + foreach my $file (glob("$old_path/*.css")) { + unlink($file); + } + rmdir($old_path); + } +} + sub create_htaccess { _create_files(%{FILESYSTEM()->{htaccess}}); @@ -730,6 +819,59 @@ sub _update_old_charts { } } +# The old naming scheme has product names as mining file names; we rename them +# to product IDs. +sub _update_old_mining_filenames { + my ($miningdir) = @_; + my @conversion_errors; + + require Bugzilla::Product; + + # We use a dummy product instance with ID 0, representing all products + my $product_all = {id => 0, name => '-All-'}; + bless($product_all, 'Bugzilla::Product'); + + print "Updating old charting data file names..."; + my @products = Bugzilla::Product->get_all(); + push(@products, $product_all); + foreach my $product (@products) { + if (-e File::Spec->catfile($miningdir, $product->id)) { + push(@conversion_errors, + { product => $product, + message => 'A file named "' . $product->id . + '" already exists.' }); + } + } + + if (! @conversion_errors) { + # Renaming mining files should work now without a hitch. + foreach my $product (@products) { + if (! rename(File::Spec->catfile($miningdir, $product->name), + File::Spec->catfile($miningdir, $product->id))) { + push(@conversion_errors, + { product => $product, + message => $! }); + } + } + } + + # Error reporting + if (! @conversion_errors) { + print " done.\n"; + } + else { + print " FAILED:\n"; + foreach my $error (@conversion_errors) { + printf "Cannot rename charting data file for product %d (%s): %s\n", + $error->{product}->id, $error->{product}->name, + $error->{message}; + } + print "You need to empty the \"$miningdir\" directory, then run\n", + " collectstats.pl --regenerate\n", + "in order to clean this up.\n"; + } +} + sub fix_dir_permissions { my ($dir) = @_; return if ON_WINDOWS; @@ -961,3 +1103,29 @@ how they are supposed to be set in Bugzilla's current configuration. If it fails to set the permissions, a warning will be printed to STDERR. =back + +=head1 B<Methods in need of POD> + +=over + +=item CGI_WRITE + +=item DIR_WS_SERVE + +=item DIR_ALSO_WS_SERVE + +=item WS_SERVE + +=item FILESYSTEM + +=item WS_EXECUTE + +=item CGI_READ + +=item DIR_CGI_READ + +=item DIR_CGI_WRITE + +=item DIR_CGI_OVERWRITE + +=back diff --git a/Bugzilla/Install/Localconfig.pm b/Bugzilla/Install/Localconfig.pm index 881f6c956..7f473cc77 100644 --- a/Bugzilla/Install/Localconfig.pm +++ b/Bugzilla/Install/Localconfig.pm @@ -15,7 +15,9 @@ package Bugzilla::Install::Localconfig; # * Files do not have the correct permissions # * The database is not up to date +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; use Bugzilla::Install::Util qw(bin_loc install_string); @@ -26,7 +28,7 @@ use File::Basename qw(dirname); use Safe; use Term::ANSIColor; -use base qw(Exporter); +use parent qw(Exporter); our @EXPORT_OK = qw( read_localconfig @@ -79,12 +81,24 @@ use constant LOCALCONFIG_VARS => ( default => 1, }, { - name => 'index_html', - default => 0, + name => 'db_mysql_ssl_ca_file', + default => '', + }, + { + name => 'db_mysql_ssl_ca_path', + default => '', + }, + { + name => 'db_mysql_ssl_client_cert', + default => '', }, { - name => 'cvsbin', - default => sub { bin_loc('cvs') }, + name => 'db_mysql_ssl_client_key', + default => '', + }, + { + name => 'index_html', + default => 0, }, { name => 'interdiffbin', diff --git a/Bugzilla/Install/Requirements.pm b/Bugzilla/Install/Requirements.pm index fbd7d7882..f723543b8 100644 --- a/Bugzilla/Install/Requirements.pm +++ b/Bugzilla/Install/Requirements.pm @@ -13,23 +13,17 @@ package Bugzilla::Install::Requirements; # Subroutines may "require" and "import" from modules, but they # MUST NOT "use." +use 5.10.1; use strict; -use version; +use warnings; use Bugzilla::Constants; -use Bugzilla::Install::Util qw(vers_cmp install_string bin_loc +use Bugzilla::Install::Util qw(install_string bin_loc extension_requirement_packages); use List::Util qw(max); use Term::ANSIColor; -# Return::Value 1.666002 pollutes the error log with warnings about this -# deprecated module. We have to set NO_CLUCK = 1 before loading Email::Send -# in have_vers() to disable these warnings. -BEGIN { - $Return::Value::NO_CLUCK = 1; -} - -use base qw(Exporter); +use parent qw(Exporter); our @EXPORT = qw( REQUIRED_MODULES OPTIONAL_MODULES @@ -55,6 +49,8 @@ use constant APACHE_MODULES => { mod_headers => 'headers_module', mod_env => 'env_module', mod_expires => 'expires_module', + mod_rewrite => 'rewrite_module', + mod_version => 'version_module' }; # These are all of the binaries that we could possibly use that can @@ -86,7 +82,6 @@ use constant APACHE_PATH => [qw( # are 'blacklisted'--that is, even if the version is high enough, Bugzilla # will refuse to say that it's OK to run with that version. sub REQUIRED_MODULES { - my $perl_ver = sprintf('%vd', $^V); my @modules = ( { package => 'CGI.pm', @@ -106,40 +101,36 @@ sub REQUIRED_MODULES { module => 'Date::Format', version => '2.23' }, - # 0.28 fixed some important bugs in DateTime. + # 0.75 fixes a warning thrown with Perl 5.17 and newer. { package => 'DateTime', module => 'DateTime', - version => '0.28' + version => '0.75' }, - # 0.79 is required to work on Windows Vista and Windows Server 2008. - # As correctly detecting the flavor of Windows is not easy, - # we require this version for all Windows installations. - # 0.71 fixes a major bug affecting all platforms. + # 1.64 fixes a taint issue preventing the local timezone from + # being determined on some systems. { package => 'DateTime-TimeZone', module => 'DateTime::TimeZone', - version => ON_WINDOWS ? '0.79' : '0.71' + version => '1.64' }, # 1.54 is required for Perl 5.10+. It also makes DBD::Oracle happy. { package => 'DBI', module => 'DBI', - version => (vers_cmp($perl_ver, '5.13.3') > -1) ? '1.614' : '1.54' + version => ($^V >= v5.13.3) ? '1.614' : '1.54' }, - # 2.22 fixes various problems related to UTF8 strings in hash keys, - # as well as line endings on Windows. + # 2.24 contains several useful text virtual methods. { package => 'Template-Toolkit', module => 'Template', - version => '2.22' + version => '2.24' }, - # 2.04 implement the "Test" method (to write to data/mailer.testfile). + # 1.300011 has a debug mode for SMTP and automatically pass -i to sendmail. { - package => 'Email-Send', - module => 'Email::Send', - version => ON_WINDOWS ? '2.16' : '2.04', - blacklist => ['^2\.196$'] + package => 'Email-Sender', + module => 'Email::Sender', + version => '1.300011', }, { package => 'Email-MIME', @@ -150,9 +141,8 @@ sub REQUIRED_MODULES { { package => 'URI', module => 'URI', - # This version properly handles a semicolon as the delimiter - # in a URL query string. - version => '1.37', + # Follows RFC 3986 to escape characters in URI::Escape. + version => '1.55', }, # 0.32 fixes several memory leaks in the XS version of some functions. { @@ -165,10 +155,22 @@ sub REQUIRED_MODULES { module => 'Math::Random::ISAAC', version => '1.0.1', }, + { + package => 'File-Slurp', + module => 'File::Slurp', + version => '9999.13', + }, + { + package => 'JSON-XS', + module => 'JSON::XS', + # 2.0 is the first version that will work with JSON::RPC. + version => '2.01', + }, ); if (ON_WINDOWS) { - push(@modules, { + push(@modules, + { package => 'Win32', module => 'Win32', # 0.35 fixes a memory leak in GetOSVersion, which we use. @@ -179,7 +181,14 @@ sub REQUIRED_MODULES { module => 'Win32::API', # 0.55 fixes a bug with char* that might affect Bugzilla::RNG. version => '0.55', - }); + }, + { + package => 'DateTime-TimeZone-Local-Win32', + module => 'DateTime::TimeZone::Local::Win32', + # We require DateTime::TimeZone 1.64, so this version must match. + version => '1.64', + } + ); } my $extra_modules = _get_extension_requirements('REQUIRED_MODULES'); @@ -188,7 +197,6 @@ sub REQUIRED_MODULES { }; sub OPTIONAL_MODULES { - my $perl_ver = sprintf('%vd', $^V); my @modules = ( { package => 'GD', @@ -199,10 +207,9 @@ sub OPTIONAL_MODULES { { package => 'Chart', module => 'Chart::Lines', - # Versions below 2.1 cannot be detected accurately. - # There is no 2.1.0 release (it was 2.1), but .0 is required to fix + # Versions below 2.4.1 cannot be compared accurately, see # https://rt.cpan.org/Public/Bug/Display.html?id=28218. - version => '2.1.0', + version => '2.4.1', feature => [qw(new_charts old_charts)], }, { @@ -283,6 +290,8 @@ sub OPTIONAL_MODULES { # Fixes various bugs, including 542931 and 552353 + stops # throwing warnings with Perl 5.12. version => '0.712', + # SOAP::Transport::HTTP 1.12 is bogus. + blacklist => ['^1\.12$'], feature => ['xmlrpc'], }, # Since SOAP::Lite 1.0, XMLRPC::Lite is no longer included @@ -297,26 +306,20 @@ sub OPTIONAL_MODULES { package => 'JSON-RPC', module => 'JSON::RPC', version => 0, - feature => ['jsonrpc'], - }, - { - package => 'JSON-XS', - module => 'JSON::XS', - # 2.0 is the first version that will work with JSON::RPC. - version => '2.0', - feature => ['jsonrpc_faster'], + feature => ['jsonrpc', 'rest'], }, { package => 'Test-Taint', module => 'Test::Taint', - version => 0, - feature => ['jsonrpc', 'xmlrpc'], + # 1.06 no longer throws warnings with Perl 5.10+. + version => 1.06, + feature => ['jsonrpc', 'xmlrpc', 'rest'], }, { # We need the 'utf8_mode' method of HTML::Parser, for HTML::Scrubber. package => 'HTML-Parser', module => 'HTML::Parser', - version => (vers_cmp($perl_ver, '5.13.3') > -1) ? '3.67' : '3.40', + version => ($^V >= v5.13.3) ? '3.67' : '3.40', feature => ['html_desc'], }, { @@ -368,12 +371,6 @@ sub OPTIONAL_MODULES { version => 0, feature => ['jobqueue'], }, - { - package => 'File-Slurp', - module => 'File::Slurp', - version => '9999.13', - feature => ['jobqueue'], - }, # mod_perl { @@ -403,6 +400,22 @@ sub OPTIONAL_MODULES { version => '0', feature => ['typesniffer'], }, + + # memcached + { + package => 'Cache-Memcached', + module => 'Cache::Memcached', + version => '0', + feature => ['memcached'], + }, + + # Documentation + { + package => 'File-Copy-Recursive', + module => 'File::Copy::Recursive', + version => 0, + feature => ['documentation'], + } ); my $extra_modules = _get_extension_requirements('OPTIONAL_MODULES'); @@ -416,14 +429,18 @@ 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', + 'Bugzilla/WebService/Server/REST/Resources/*.pm'], moving => ['importxml.pl'], auth_ldap => ['Bugzilla/Auth/Verify/LDAP.pm'], auth_radius => ['Bugzilla/Auth/Verify/RADIUS.pm'], + documentation => ['docs/makedocs.pl'], inbound_email => ['email_in.pl'], jobqueue => ['Bugzilla/Job/*', 'Bugzilla/JobQueue.pm', 'Bugzilla/JobQueue/*', 'jobqueue.pl'], patch_viewer => ['Bugzilla/Attachment/PatchReader.pm'], updates => ['Bugzilla/Update.pm'], + memcached => ['Bugzilla/Memcache.pm'], ); # This implements the REQUIRED_MODULES and OPTIONAL_MODULES stuff @@ -506,7 +523,7 @@ sub _missing_apache_modules { return []; } my @missing; - foreach my $module (keys %$modules) { + foreach my $module (sort keys %$modules) { my $ok = _check_apache_module($module, $modules->{$module}, $cmd_info, $output); push(@missing, $module) if !$ok; @@ -940,3 +957,11 @@ Returns a hashref where file names are the keys and the value is the feature that must be enabled in order to compile that file. =back + +=head1 B<Methods in need of POD> + +=over + +=item print_module_instructions + +=back diff --git a/Bugzilla/Install/Util.pm b/Bugzilla/Install/Util.pm index cbc41db32..c05037061 100644 --- a/Bugzilla/Install/Util.pm +++ b/Bugzilla/Install/Util.pm @@ -11,7 +11,9 @@ package Bugzilla::Install::Util; # module may require *only* Bugzilla::Constants and built-in # perl modules. +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; @@ -23,7 +25,7 @@ use Scalar::Util qw(tainted); use Term::ANSIColor qw(colored); use PerlIO; -use base qw(Exporter); +use parent qw(Exporter); our @EXPORT_OK = qw( bin_loc get_version_and_os @@ -37,7 +39,6 @@ our @EXPORT_OK = qw( include_languages success template_include_path - vers_cmp init_console ); @@ -475,49 +476,6 @@ sub template_include_path { return \@include_path; } -# This is taken straight from Sort::Versions 1.5, which is not included -# with perl by default. -sub vers_cmp { - my ($a, $b) = @_; - - # Remove leading zeroes - Bug 344661 - $a =~ s/^0*(\d.+)/$1/; - $b =~ s/^0*(\d.+)/$1/; - - my @A = ($a =~ /([-.]|\d+|[^-.\d]+)/g); - my @B = ($b =~ /([-.]|\d+|[^-.\d]+)/g); - - my ($A, $B); - while (@A and @B) { - $A = shift @A; - $B = shift @B; - if ($A eq '-' and $B eq '-') { - next; - } elsif ( $A eq '-' ) { - return -1; - } elsif ( $B eq '-') { - return 1; - } elsif ($A eq '.' and $B eq '.') { - next; - } elsif ( $A eq '.' ) { - return -1; - } elsif ( $B eq '.' ) { - return 1; - } elsif ($A =~ /^\d+$/ and $B =~ /^\d+$/) { - if ($A =~ /^0/ || $B =~ /^0/) { - return $A cmp $B if $A cmp $B; - } else { - return $A <=> $B if $A <=> $B; - } - } else { - $A = uc $A; - $B = uc $B; - return $A cmp $B if $A cmp $B; - } - } - @A <=> @B; -} - sub no_checksetup_from_cgi { print "Content-Type: text/html; charset=UTF-8\r\n\r\n"; print install_string('no_checksetup_from_cgi'); @@ -893,26 +851,36 @@ Used by L<Bugzilla::Template> to determine the languages' list which are compiled with the browser's I<Accept-Language> and the languages of installed templates. -=item C<vers_cmp> +=back + +=head1 B<Methods in need of POD> =over -=item B<Description> +=item supported_languages -This is a comparison function, like you would use in C<sort>, except that -it compares two version numbers. So, for example, 2.10 would be greater -than 2.2. +=item extension_template_directory -It's based on versioncmp from L<Sort::Versions>, with some Bugzilla-specific -fixes. +=item extension_code_files -=item B<Params>: C<$a> and C<$b> - The versions you want to compare. +=item extension_web_directory -=item B<Returns> +=item trick_taint -C<-1> if C<$a> is less than C<$b>, C<0> if they are equal, or C<1> if C<$a> -is greater than C<$b>. +=item success -=back +=item trim + +=item extension_package_directory + +=item set_output_encoding + +=item extension_requirement_packages + +=item prevent_windows_dialog_boxes + +=item sortQvalue + +=item no_checksetup_from_cgi =back diff --git a/Bugzilla/Job/BugMail.pm b/Bugzilla/Job/BugMail.pm new file mode 100644 index 000000000..e0b7f5448 --- /dev/null +++ b/Bugzilla/Job/BugMail.pm @@ -0,0 +1,32 @@ +# 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 warnings; + +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/Job/Mailer.pm b/Bugzilla/Job/Mailer.pm index 958089e45..cd1c23445 100644 --- a/Bugzilla/Job/Mailer.pm +++ b/Bugzilla/Job/Mailer.pm @@ -6,9 +6,13 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Job::Mailer; + +use 5.10.1; use strict; +use warnings; + use Bugzilla::Mailer; -BEGIN { eval "use base qw(TheSchwartz::Worker)"; } +BEGIN { eval "use parent qw(TheSchwartz::Worker)"; } # The longest we expect a job to possibly take, in seconds. use constant grab_for => 300; diff --git a/Bugzilla/JobQueue.pm b/Bugzilla/JobQueue.pm index 2c3d8a773..d5ceda8e9 100644 --- a/Bugzilla/JobQueue.pm +++ b/Bugzilla/JobQueue.pm @@ -7,7 +7,9 @@ package Bugzilla::JobQueue; +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; use Bugzilla::Error; @@ -21,6 +23,7 @@ use fields qw(_worker_pidfile); # 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 @@ -28,6 +31,10 @@ use constant JOB_MAP => { # across requests. use constant DRIVER_CACHE_TIME => 300; # 5 minutes +# To avoid memory leak/fragmentation, a worker process won't process more than +# MAX_MESSAGES messages. +use constant MAX_MESSAGES => 1000; + sub job_map { if (!defined(Bugzilla->request_cache->{job_map})) { my $job_map = JOB_MAP; @@ -153,6 +160,16 @@ sub work_once { return $self->SUPER::work_once(@_); } +# Never process more than MAX_MESSAGES in one batch, to avoid memory +# leak/fragmentation issues. +sub work_until_done { + my $self = shift; + my $count = 0; + while ($count++ < MAX_MESSAGES) { + $self->work_once or last; + } +} + 1; __END__ @@ -178,3 +195,19 @@ Bugzilla to use some sort of service to schedule jobs to happen asyncronously. See the synopsis above for an easy to follow example on how to insert a job into the queue. Give it a name and some arguments and the job will be sent away to be done later. + +=head1 B<Methods in need of POD> + +=over + +=item insert + +=item bz_databases + +=item job_map + +=item set_pidfile + +=item kill_worker + +=back diff --git a/Bugzilla/JobQueue/Runner.pm b/Bugzilla/JobQueue/Runner.pm index 8b5cf9b6b..104a97b0b 100644 --- a/Bugzilla/JobQueue/Runner.pm +++ b/Bugzilla/JobQueue/Runner.pm @@ -11,7 +11,10 @@ package Bugzilla::JobQueue::Runner; +use 5.10.1; use strict; +use warnings; + use Cwd qw(abs_path); use File::Basename; use File::Copy; @@ -20,7 +23,7 @@ use Pod::Usage; use Bugzilla::Constants; use Bugzilla::JobQueue; use Bugzilla::Util qw(get_text); -BEGIN { eval "use base qw(Daemon::Generic)"; } +BEGIN { eval "use parent qw(Daemon::Generic)"; } our $VERSION = BUGZILLA_VERSION; @@ -235,3 +238,33 @@ job queue. This is a subclass of L<Daemon::Generic> that is used by L<jobqueue> to run the Bugzilla job queue. + +=head1 B<Methods in need of POD> + +=over + +=item gd_check + +=item gd_run + +=item gd_can_install + +=item gd_quit_event + +=item gd_other_cmd + +=item gd_more_opt + +=item gd_postconfig + +=item gd_usage + +=item gd_getopt + +=item gd_preconfig + +=item gd_can_uninstall + +=item gd_setup_signals + +=back diff --git a/Bugzilla/Keyword.pm b/Bugzilla/Keyword.pm index 3f3213be4..afa93e1e9 100644 --- a/Bugzilla/Keyword.pm +++ b/Bugzilla/Keyword.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Keyword; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::Error; use Bugzilla::Util; @@ -18,6 +20,8 @@ use Bugzilla::Util; #### Initialization #### ############################### +use constant IS_CONFIG => 1; + use constant DB_COLUMNS => qw( keyworddefs.id keyworddefs.name @@ -165,3 +169,17 @@ implements. =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item set_description + +=item bug_count + +=item set_name + +=item description + +=back diff --git a/Bugzilla/Mailer.pm b/Bugzilla/Mailer.pm index 64640150b..0b82ded41 100644 --- a/Bugzilla/Mailer.pm +++ b/Bugzilla/Mailer.pm @@ -7,9 +7,11 @@ package Bugzilla::Mailer; +use 5.10.1; use strict; +use warnings; -use base qw(Exporter); +use parent qw(Exporter); @Bugzilla::Mailer::EXPORT = qw(MessageToMTA build_thread_marker); use Bugzilla::Constants; @@ -21,22 +23,20 @@ use Date::Format qw(time2str); use Encode qw(encode); use Encode::MIME::Header; -use Email::Address; use Email::MIME; -# Return::Value 1.666002 pollutes the error log with warnings about this -# deprecated module. We have to set NO_CLUCK = 1 before loading Email::Send -# to disable these warnings. -BEGIN { - $Return::Value::NO_CLUCK = 1; -} -use Email::Send; +use Email::Sender::Simple qw(sendmail); +use Email::Sender::Transport::SMTP::Persistent; +use Bugzilla::Sender::Transport::Sendmail; sub MessageToMTA { my ($msg, $send_now) = (@_); my $method = Bugzilla->params->{'mail_delivery_method'}; return if $method eq 'None'; - if (Bugzilla->params->{'use_mailer_queue'} and !$send_now) { + if (Bugzilla->params->{'use_mailer_queue'} + && ! $send_now + && ! Bugzilla->dbh->bz_in_transaction() + ) { Bugzilla->job_queue->insert('send_mail', { msg => $msg }); return; } @@ -50,12 +50,22 @@ sub MessageToMTA { # Email::MIME doesn't do this for us. We use \015 (CR) and \012 (LF) # directly because Perl translates "\n" depending on what platform # you're running on. See http://perldoc.perl.org/perlport.html#Newlines - # We check for multiple CRs because of this Template-Toolkit bug: - # https://rt.cpan.org/Ticket/Display.html?id=43345 $msg =~ s/(?:\015+)?\012/\015\012/msg; $email = new Email::MIME($msg); } + # If we're called from within a transaction, we don't want to send the + # email immediately, in case the transaction is rolled back. Instead we + # insert it into the mail_staging table, and bz_commit_transaction calls + # send_staged_mail() after the transaction is committed. + if (! $send_now && Bugzilla->dbh->bz_in_transaction()) { + # The e-mail string may contain tainted values. + my $string = $email->as_string; + trick_taint($string); + Bugzilla->dbh->do("INSERT INTO mail_staging (message) VALUES(?)", undef, $string); + return; + } + # We add this header to uniquely identify all email that we # send as coming from this Bugzilla installation. # @@ -63,7 +73,7 @@ sub MessageToMTA { # *always* be the same for this Bugzilla, in every email, # even if the admin changes the "ssl_redirect" parameter some day. $email->header_set('X-Bugzilla-URL', Bugzilla->params->{'urlbase'}); - + # We add this header to mark the mail as "auto-generated" and # thus to hopefully avoid auto replies. $email->header_set('Auto-Submitted', 'auto-generated'); @@ -91,21 +101,14 @@ sub MessageToMTA { my $from = $email->header('From'); - my ($hostname, @args); - my $mailer_class = $method; + my $hostname; + my $transport; if ($method eq "Sendmail") { - $mailer_class = 'Bugzilla::Send::Sendmail'; if (ON_WINDOWS) { - $Email::Send::Sendmail::SENDMAIL = SENDMAIL_EXE; + $transport = Bugzilla::Sender::Transport::Sendmail->new({ sendmail => SENDMAIL_EXE }); } - push @args, "-i"; - # We want to make sure that we pass *only* an email address. - if ($from) { - my ($email_obj) = Email::Address->parse($from); - if ($email_obj) { - my $from_email = $email_obj->address; - push(@args, "-f$from_email") if $from_email; - } + else { + $transport = Bugzilla::Sender::Transport::Sendmail->new(); } } else { @@ -113,7 +116,7 @@ sub MessageToMTA { # address, but other mailers won't. my $urlbase = Bugzilla->params->{'urlbase'}; $urlbase =~ m|//([^:/]+)[:/]?|; - $hostname = $1; + $hostname = $1 || 'localhost'; $from .= "\@$hostname" if $from !~ /@/; $email->header_set('From', $from); @@ -124,16 +127,21 @@ sub MessageToMTA { } if ($method eq "SMTP") { - push @args, Host => Bugzilla->params->{"smtpserver"}, - username => Bugzilla->params->{"smtp_username"}, - password => Bugzilla->params->{"smtp_password"}, - Hello => $hostname, - ssl => Bugzilla->params->{'smtp_ssl'}, - Debug => Bugzilla->params->{'smtp_debug'}; + my ($host, $port) = split(/:/, Bugzilla->params->{'smtpserver'}, 2); + $transport = Bugzilla->request_cache->{smtp} //= + Email::Sender::Transport::SMTP::Persistent->new({ + host => $host, + defined($port) ? (port => $port) : (), + sasl_username => Bugzilla->params->{'smtp_username'}, + sasl_password => Bugzilla->params->{'smtp_password'}, + helo => $hostname, + ssl => Bugzilla->params->{'smtp_ssl'}, + debug => Bugzilla->params->{'smtp_debug'} }); } - Bugzilla::Hook::process('mailer_before_send', - { email => $email, mailer_args => \@args }); + Bugzilla::Hook::process('mailer_before_send', { email => $email }); + + return if $email->header('to') eq ''; $email->walk_parts(sub { my ($part) = @_; @@ -166,13 +174,12 @@ sub MessageToMTA { close TESTFILE; } else { - # This is useful for both Sendmail and Qmail, so we put it out here. + # This is useful for Sendmail, so we put it out here. local $ENV{PATH} = SENDMAIL_PATH; - my $mailer = Email::Send->new({ mailer => $mailer_class, - mailer_args => \@args }); - my $retval = $mailer->send($email); - ThrowCodeError('mail_send_error', { msg => $retval, mail => $email }) - if !$retval; + eval { sendmail($email, { transport => $transport }) }; + if ($@) { + ThrowCodeError('mail_send_error', { msg => $@->message, mail => $email }); + } } } @@ -205,4 +212,50 @@ sub build_thread_marker { return $threadingmarker; } +sub send_staged_mail { + my $dbh = Bugzilla->dbh; + my @ids; + my $emails + = $dbh->selectall_arrayref("SELECT id, message FROM mail_staging"); + + foreach my $row (@$emails) { + MessageToMTA($row->[1]); + push(@ids, $row->[0]); + } + + if (@ids) { + $dbh->do("DELETE FROM mail_staging WHERE " . $dbh->sql_in('id', \@ids)); + } +} + 1; + +__END__ + +=head1 NAME + +Bugzilla::Mailer - Provides methods for sending email + +=head1 METHODS + +=over + +=item C<MessageToMTA> + +Sends the passed message to the mail transfer agent. + +The actual behaviour depends on a number of factors: if called from within a +database transaction, the message will be staged and sent when the transaction +is committed. If email queueing is enabled, the message will be sent to +TheSchwartz job queue where it will be processed by the jobqueue daemon, else +the message is sent immediately. + +=item C<build_thread_marker> + +Builds header suitable for use as a threading marker in email notifications. + +=item C<send_staged_mail> + +Sends all staged messages -- called after a database transaction is committed. + +=back diff --git a/Bugzilla/Memcached.pm b/Bugzilla/Memcached.pm new file mode 100644 index 000000000..df90fef93 --- /dev/null +++ b/Bugzilla/Memcached.pm @@ -0,0 +1,483 @@ +# 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::Memcached; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Util qw(trick_taint); +use Scalar::Util qw(blessed); +use URI::Escape; + +# memcached keys have a maximum length of 250 bytes +use constant MAX_KEY_LENGTH => 250; + +sub _new { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my $self = {}; + + # always return an object to simplify calling code when memcached is + # disabled. + if (Bugzilla->feature('memcached') + && Bugzilla->params->{memcached_servers}) + { + require Cache::Memcached; + $self->{namespace} = Bugzilla->params->{memcached_namespace} || ''; + $self->{memcached} = + Cache::Memcached->new({ + servers => [ split(/[, ]+/, Bugzilla->params->{memcached_servers}) ], + namespace => $self->{namespace}, + }); + } + return bless($self, $class); +} + +sub enabled { + return $_[0]->{memcached} ? 1 : 0; +} + +sub set { + my ($self, $args) = @_; + return unless $self->{memcached}; + + # { key => $key, value => $value } + if (exists $args->{key}) { + $self->_set($args->{key}, $args->{value}); + } + + # { table => $table, id => $id, name => $name, data => $data } + elsif (exists $args->{table} && exists $args->{id} && exists $args->{name}) { + # For caching of Bugzilla::Object, we have to be able to clear the + # cached values when given either the object's id or name. + my ($table, $id, $name, $data) = @$args{qw(table id name data)}; + $self->_set("$table.id.$id", $data); + if (defined $name) { + $self->_set("$table.name_id.$name", $id); + $self->_set("$table.id_name.$id", $name); + } + } + + else { + ThrowCodeError('params_required', { function => "Bugzilla::Memcached::set", + params => [ 'key', 'table' ] }); + } +} + +sub get { + my ($self, $args) = @_; + return unless $self->{memcached}; + + # { key => $key } + if (exists $args->{key}) { + return $self->_get($args->{key}); + } + + # { table => $table, id => $id } + elsif (exists $args->{table} && exists $args->{id}) { + my ($table, $id) = @$args{qw(table id)}; + return $self->_get("$table.id.$id"); + } + + # { table => $table, name => $name } + elsif (exists $args->{table} && exists $args->{name}) { + my ($table, $name) = @$args{qw(table name)}; + return unless my $id = $self->_get("$table.name_id.$name"); + return $self->_get("$table.id.$id"); + } + + else { + ThrowCodeError('params_required', { function => "Bugzilla::Memcached::get", + params => [ 'key', 'table' ] }); + } +} + +sub set_config { + my ($self, $args) = @_; + return unless $self->{memcached}; + + if (exists $args->{key}) { + return $self->_set($self->_config_prefix . '.' . $args->{key}, $args->{data}); + } + else { + ThrowCodeError('params_required', { function => "Bugzilla::Memcached::set_config", + params => [ 'key' ] }); + } +} + +sub get_config { + my ($self, $args) = @_; + return unless $self->{memcached}; + + if (exists $args->{key}) { + return $self->_get($self->_config_prefix . '.' . $args->{key}); + } + else { + ThrowCodeError('params_required', { function => "Bugzilla::Memcached::get_config", + params => [ 'key' ] }); + } +} + +sub clear { + my ($self, $args) = @_; + return unless $self->{memcached}; + + # { key => $key } + if (exists $args->{key}) { + $self->_delete($args->{key}); + } + + # { table => $table, id => $id } + elsif (exists $args->{table} && exists $args->{id}) { + my ($table, $id) = @$args{qw(table id)}; + my $name = $self->_get("$table.id_name.$id"); + $self->_delete("$table.id.$id"); + $self->_delete("$table.name_id.$name") if defined $name; + $self->_delete("$table.id_name.$id"); + } + + # { table => $table, name => $name } + elsif (exists $args->{table} && exists $args->{name}) { + my ($table, $name) = @$args{qw(table name)}; + return unless my $id = $self->_get("$table.name_id.$name"); + $self->_delete("$table.id.$id"); + $self->_delete("$table.name_id.$name"); + $self->_delete("$table.id_name.$id"); + } + + else { + ThrowCodeError('params_required', { function => "Bugzilla::Memcached::clear", + params => [ 'key', 'table' ] }); + } +} + +sub clear_all { + my ($self) = @_; + return unless $self->{memcached}; + $self->_inc_prefix("global"); +} + +sub clear_config { + my ($self, $args) = @_; + return unless $self->{memcached}; + if ($args && exists $args->{key}) { + $self->_delete($self->_config_prefix . '.' . $args->{key}); + } + else { + $self->_inc_prefix("config"); + } +} + +# in order to clear all our keys, we add a prefix to all our keys. when we +# need to "clear" all current keys, we increment the prefix. +sub _prefix { + my ($self, $name) = @_; + # we don't want to change prefixes in the middle of a request + my $request_cache = Bugzilla->request_cache; + my $request_cache_key = "memcached_prefix_$name"; + if (!$request_cache->{$request_cache_key}) { + my $memcached = $self->{memcached}; + my $prefix = $memcached->get($name); + if (!$prefix) { + $prefix = time(); + if (!$memcached->add($name, $prefix)) { + # if this failed, either another process set the prefix, or + # memcached is down. assume we lost the race, and get the new + # value. if that fails, memcached is down so use a dummy + # prefix for this request. + $prefix = $memcached->get($name) || 0; + } + } + $request_cache->{$request_cache_key} = $prefix; + } + return $request_cache->{$request_cache_key}; +} + +sub _inc_prefix { + my ($self, $name) = @_; + my $memcached = $self->{memcached}; + if (!$memcached->incr($name, 1)) { + $memcached->add($name, time()); + } + delete Bugzilla->request_cache->{"memcached_prefix_$name"}; +} + +sub _global_prefix { + return $_[0]->_prefix("global"); +} + +sub _config_prefix { + return $_[0]->_prefix("config"); +} + +sub _encode_key { + my ($self, $key) = @_; + $key = $self->_global_prefix . '.' . uri_escape_utf8($key); + return length($self->{namespace} . $key) > MAX_KEY_LENGTH + ? undef + : $key; +} + +sub _set { + my ($self, $key, $value) = @_; + if (blessed($value)) { + # we don't support blessed objects + ThrowCodeError('param_invalid', { function => "Bugzilla::Memcached::set", + param => "value" }); + } + + $key = $self->_encode_key($key) + or return; + return $self->{memcached}->set($key, $value); +} + +sub _get { + my ($self, $key) = @_; + + $key = $self->_encode_key($key) + or return; + my $value = $self->{memcached}->get($key); + return unless defined $value; + + # detaint returned values + # hashes and arrays are detainted just one level deep + if (ref($value) eq 'HASH') { + _detaint_hashref($value); + } + elsif (ref($value) eq 'ARRAY') { + foreach my $value (@$value) { + next unless defined $value; + # arrays of hashes and arrays are common + if (ref($value) eq 'HASH') { + _detaint_hashref($value); + } + elsif (ref($value) eq 'ARRAY') { + _detaint_arrayref($value); + } + elsif (!ref($value)) { + trick_taint($value); + } + } + } + elsif (!ref($value)) { + trick_taint($value); + } + return $value; +} + +sub _detaint_hashref { + my ($hashref) = @_; + foreach my $value (values %$hashref) { + if (defined($value) && !ref($value)) { + trick_taint($value); + } + } +} + +sub _detaint_arrayref { + my ($arrayref) = @_; + foreach my $value (@$arrayref) { + if (defined($value) && !ref($value)) { + trick_taint($value); + } + } +} + +sub _delete { + my ($self, $key) = @_; + $key = $self->_encode_key($key) + or return; + return $self->{memcached}->delete($key); +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Memcached - Interface between Bugzilla and Memcached. + +=head1 SYNOPSIS + + use Bugzilla; + + my $memcached = Bugzilla->memcached; + + # grab data from the cache. there is no need to check if memcached is + # available or enabled. + my $data = $memcached->get({ key => 'data_key' }); + if (!defined $data) { + # not in cache, generate the data and populate the cache for next time + $data = some_long_process(); + $memcached->set({ key => 'data_key', value => $data }); + } + # do something with $data + + # updating the profiles table directly shouldn't be attempted unless you know + # what you're doing. if you do update a table directly, you need to clear that + # object from memcached. + $dbh->do("UPDATE profiles SET request_count=10 WHERE login_name=?", undef, $login); + $memcached->clear({ table => 'profiles', name => $login }); + +=head1 DESCRIPTION + +If Memcached is installed and configured, Bugzilla can use it to cache data +across requests and between webheads. Unlike the request and process caches, +only scalars, hashrefs, and arrayrefs can be stored in Memcached. + +Memcached integration is only required for large installations of Bugzilla -- +if you have multiple webheads then configuring Memcache is recommended. + +L<Bugzilla::Memcached> provides an interface to a Memcached server/servers, with +the ability to get, set, or clear entries from the cache. + +The stored value must be an unblessed hashref, unblessed array ref, or a +scalar. Currently nested data structures are supported but require manual +de-tainting after reading from Memcached (flat data structures are automatically +de-tainted). + +All values are stored in the Memcached systems using the prefix configured with +the C<memcached_namespace> parameter, as well as an additional prefix managed +by this class to allow all values to be cleared when C<checksetup.pl> is +executed. + +Do not create an instance of this object directly, instead use +L<Bugzilla-E<gt>memcached()|Bugzilla/memcached>. + +=head1 METHODS + +=over + +=item C<enabled> + +Returns true if Memcached support is available and enabled. + +=back + +=head2 Setting + +Adds a value to Memcached. + +=over + +=item C<set({ key =E<gt> $key, value =E<gt> $value })> + +Adds the C<value> using the specific C<key>. + +=item C<set({ table =E<gt> $table, id =E<gt> $id, name =E<gt> $name, data =E<gt> $data })> + +Adds the C<data> using a keys generated from the C<table>, C<id>, and C<name>. +All three parameters must be provided, however C<name> can be provided but set +to C<undef>. + +This is a convenience method which allows cached data to be later retrieved by +specifying the C<table> and either the C<id> or C<name>. + +=item C<set_config({ key =E<gt> $key, data =E<gt> $data })> + +Adds the C<data> using the C<key> while identifying the data as part of +Bugzilla's configuration (such as fields, products, components, groups, etc). +Values set with C<set_config> are automatically cleared when changes are made +to Bugzilla's configuration. + +=back + +=head2 Getting + +Retrieves a value from Memcached. Returns C<undef> if no matching values were +found in the cache. + +=over + +=item C<get({ key =E<gt> $key })> + +Return C<value> with the specified C<key>. + +=item C<get({ table =E<gt> $table, id =E<gt> $id })> + +Return C<value> with the specified C<table> and C<id>. + +=item C<get({ table =E<gt> $table, name =E<gt> $name })> + +Return C<value> with the specified C<table> and C<name>. + +=item C<get_config({ key =E<gt> $key })> + +Return C<value> with the specified C<key> from the configuration cache. See +C<set_config> for more information. + +=back + +=head2 Clearing + +Removes the matching value from Memcached. + +=over + +=item C<clear({ key =E<gt> $key })> + +Removes C<value> with the specified C<key>. + +=item C<clear({ table =E<gt> $table, id =E<gt> $id })> + +Removes C<value> with the specified C<table> and C<id>, as well as the +corresponding C<table> and C<name> entry. + +=item C<clear({ table =E<gt> $table, name =E<gt> $name })> + +Removes C<value> with the specified C<table> and C<name>, as well as the +corresponding C<table> and C<id> entry. + +=item C<clear_config({ key =E<gt> $key })> + +Remove C<value> with the specified C<key> from the configuration cache. See +C<set_config> for more information. + +=item C<clear_config> + +Removes all configuration related values from the cache. See C<set_config> for +more information. + +=item C<clear_all> + +Removes all values from the cache. + +=back + +=head1 Bugzilla::Object CACHE + +The main driver for Memcached integration is to allow L<Bugzilla::Object> based +objects to be automatically cached in Memcache. This is enabled on a +per-package basis by setting the C<USE_MEMCACHED> constant to any true value. + +The current implementation is an opt-in (USE_MEMCACHED is false by default), +however this will change to opt-out once further testing has been completed +(USE_MEMCACHED will be true by default). + +=head1 DIRECT DATABASE UPDATES + +If an object is cached and the database is updated directly (instead of via +C<$object-E<gt>update()>), then it's possible for the data in the cache to be +out of sync with the database. + +As an example let's consider an extension which adds a timestamp field +C<last_activitiy_ts> to the profiles table and user object which contains the +user's last activity. If the extension were to call C<$user-E<gt>update()>, +then an audit entry would be created for each change to the C<last_activity_ts> +field, which is undesirable. + +To remedy this, the extension updates the table directly. It's critical with +Memcached that it then clears the cache: + + $dbh->do("UPDATE profiles SET last_activity_ts=? WHERE userid=?", + undef, $timestamp, $user_id); + Bugzilla->memcached->clear({ table => 'profiles', id => $user_id }); + diff --git a/Bugzilla/Migrate.pm b/Bugzilla/Migrate.pm index 75552c203..0731d4fed 100644 --- a/Bugzilla/Migrate.pm +++ b/Bugzilla/Migrate.pm @@ -6,7 +6,10 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Migrate; + +use 5.10.1; use strict; +use warnings; use Bugzilla::Attachment; use Bugzilla::Bug qw(LogActivityEntry); @@ -16,7 +19,7 @@ use Bugzilla::Error; use Bugzilla::Install::Requirements (); use Bugzilla::Install::Util qw(indicate_progress); use Bugzilla::Product; -use Bugzilla::Util qw(get_text trim generate_random_password say); +use Bugzilla::Util qw(get_text trim generate_random_password); use Bugzilla::User (); use Bugzilla::Status (); use Bugzilla::Version; @@ -151,6 +154,7 @@ sub do_migration { } $dbh->bz_start_transaction(); + $self->before_read(); # Read Other Database my $users = $self->users; my $products = $self->products; @@ -445,8 +449,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; @@ -539,6 +546,7 @@ sub write_config { sub after_insert {} sub before_insert {} sub after_read {} +sub before_read {} ############# # Inserters # @@ -749,7 +757,7 @@ sub insert_bugs { # File the bug as the reporter. my $super_user = Bugzilla->user; my $reporter = Bugzilla::User->check($bug->{reporter}); - # Allow the user to file a bug in any product, no matter his current + # Allow the user to file a bug in any product, no matter their current # permissions. $reporter->{groups} = $super_user->groups; Bugzilla->set_user($reporter); @@ -1142,6 +1150,11 @@ and yet shouldn't be added to the initial description of the bug when translating bugs, then they should be listed here. See L</translate_bug> for more detail. +=head2 before_read + +This is called before any data is read from the "other bug-tracker". +The default implementation does nothing. + =head2 after_read This is run after all data is read from the other bug-tracker, but @@ -1158,3 +1171,49 @@ or any custom fields are created. The default implementation does nothing. This is run after all data is inserted into Bugzilla. The default implementation does nothing. + +=head1 B<Methods in need of POD> + +=over + +=item do_migration + +=item verbose + +=item bug_fields + +=item insert_users + +=item users + +=item check_requirements + +=item bugs + +=item map_value + +=item insert_products + +=item products + +=item translate_all_bugs + +=item config_file_name + +=item dry_run + +=item name + +=item create_custom_fields + +=item reset_serial_values + +=item read_config + +=item write_config + +=item insert_bugs + +=item create_legal_values + +=back diff --git a/Bugzilla/Migrate/Gnats.pm b/Bugzilla/Migrate/Gnats.pm index 05d3b3739..5feda4b8d 100644 --- a/Bugzilla/Migrate/Gnats.pm +++ b/Bugzilla/Migrate/Gnats.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Migrate::Gnats; + +use 5.10.1; use strict; -use base qw(Bugzilla::Migrate); +use warnings; + +use parent qw(Bugzilla::Migrate); use Bugzilla::Constants; use Bugzilla::Install::Util qw(indicate_progress); @@ -696,3 +700,23 @@ sub translate_value { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item user_map + +=item user_to_email + +=item add_user + +=item translate_value + +=item before_insert + +=item translate_bug + +=item CONFIG_VARS + +=back diff --git a/Bugzilla/Milestone.pm b/Bugzilla/Milestone.pm index b4ddaeafe..cf7e3e35f 100644 --- a/Bugzilla/Milestone.pm +++ b/Bugzilla/Milestone.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Milestone; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::Constants; use Bugzilla::Util; @@ -112,8 +114,10 @@ sub update { $dbh->do('UPDATE products SET defaultmilestone = ? WHERE id = ? AND defaultmilestone = ?', undef, ($self->name, $self->product_id, $changes->{value}->[0])); + Bugzilla->memcached->clear({ table => 'products', id => $self->product_id }); } $dbh->bz_commit_transaction(); + Bugzilla->memcached->clear_config(); return $changes; } @@ -376,3 +380,13 @@ Milestone.pm represents a Product Milestone object. Returns: A Bugzilla::Milestone object. =back + +=head1 B<Methods in need of POD> + +=over + +=item set_is_active + +=item is_active + +=back diff --git a/Bugzilla/Object.pm b/Bugzilla/Object.pm index 47b935664..8f25e2b20 100644 --- a/Bugzilla/Object.pm +++ b/Bugzilla/Object.pm @@ -5,10 +5,12 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Object; +use 5.10.1; +use strict; +use warnings; + use Bugzilla::Constants; use Bugzilla::Hook; use Bugzilla::Util; @@ -33,6 +35,15 @@ use constant AUDIT_CREATES => 1; use constant AUDIT_UPDATES => 1; use constant AUDIT_REMOVES => 1; +# When USE_MEMCACHED is true, the class is suitable for serialisation to +# Memcached. See documentation in Bugzilla::Memcached for more information. +use constant USE_MEMCACHED => 1; + +# When IS_CONFIG is true, the class is used to track seldom changed +# configuration objects. This includes, but is not limited to, fields, field +# values, keywords, products, classifications, priorities, severities, etc. +use constant IS_CONFIG => 0; + # This allows the JSON-RPC interface to return Bugzilla::Object instances # as though they were hashes. In the future, this may be modified to return # less information. @@ -45,17 +56,52 @@ 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->_object_cache_get($param); + return $object if $object; + + my ($data, $set_memcached); + if (Bugzilla->memcached->enabled + && $class->USE_MEMCACHED + && ref($param) eq 'HASH' && $param->{cache}) + { + if (defined $param->{id}) { + $data = Bugzilla->memcached->get({ + table => $class->DB_TABLE, + id => $param->{id}, + }); + } + elsif (defined $param->{name}) { + $data = Bugzilla->memcached->get({ + table => $class->DB_TABLE, + name => $param->{name}, + }); + } + $set_memcached = $data ? 0 : 1; + } + $data ||= $class->_load_from_db($param); + + if ($data && $set_memcached) { + Bugzilla->memcached->set({ + table => $class->DB_TABLE, + id => $data->{$class->ID_FIELD}, + name => $data->{$class->NAME_FIELD}, + data => $data, + }); + } + + $object = $class->new_from_hash($data); + $class->_object_cache_set($param, $object); + return $object; } - # Note: Because this uses sql_istrcmp, if you make a new object use # 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; @@ -68,19 +114,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 { @@ -107,11 +153,110 @@ 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_cache + +sub object_cache_get { + my ($class, $id) = @_; + return $class->_object_cache_get( + { id => $id, cache => 1}, + $class + ); +} + +sub object_cache_set { + my $self = shift; + return $self->_object_cache_set( + { id => $self->id, cache => 1 }, + $self + ); +} + +sub _object_cache_get { + my $class = shift; + my ($param) = @_; + my $cache_key = $class->object_cache_key($param) + || return; + return Bugzilla->request_cache->{$cache_key}; +} + +sub _object_cache_set { + my $class = shift; + my ($param, $object) = @_; + my $cache_key = $class->object_cache_key($param) + || return; + Bugzilla->request_cache->{$cache_key} = $object; +} + +sub _object_cache_remove { + my $class = shift; + my ($param) = @_; + $param->{cache} = 1; + my $cache_key = $class->object_cache_key($param) + || return; + delete Bugzilla->request_cache->{$cache_key}; +} + +sub object_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 { @@ -147,28 +292,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. @@ -252,23 +375,46 @@ sub _do_list_select { my $cols = join(',', $class->_get_db_columns); my $order = $class->LIST_ORDER; - my $sql = "SELECT $cols FROM $table"; - if (defined $where) { - $sql .= " WHERE $where "; + # Unconditional requests for configuration data are cacheable. + my ($objects, $set_memcached, $memcached_key); + if (!defined $where + && Bugzilla->memcached->enabled + && $class->IS_CONFIG) + { + $memcached_key = "$class:get_all"; + $objects = Bugzilla->memcached->get_config({ key => $memcached_key }); + $set_memcached = $objects ? 0 : 1; } - $sql .= " ORDER BY $order"; - - $sql .= " $postamble" if $postamble; - - my $dbh = Bugzilla->dbh; - # Sometimes the values are tainted, but we don't want to untaint them - # for the caller. So we copy the array. It's safe to untaint because - # they're only used in placeholders here. - my @untainted = @{ $values || [] }; - trick_taint($_) foreach @untainted; - my $objects = $dbh->selectall_arrayref($sql, {Slice=>{}}, @untainted); - bless ($_, $class) foreach @$objects; - return $objects + + if (!$objects) { + my $sql = "SELECT $cols FROM $table"; + if (defined $where) { + $sql .= " WHERE $where "; + } + $sql .= " ORDER BY $order"; + $sql .= " $postamble" if $postamble; + + my $dbh = Bugzilla->dbh; + # Sometimes the values are tainted, but we don't want to untaint them + # for the caller. So we copy the array. It's safe to untaint because + # they're only used in placeholders here. + my @untainted = @{ $values || [] }; + trick_taint($_) foreach @untainted; + $objects = $dbh->selectall_arrayref($sql, {Slice=>{}}, @untainted); + $class->_serialisation_keys($objects->[0]) if @$objects; + } + + if ($objects && $set_memcached) { + Bugzilla->memcached->set_config({ + key => $memcached_key, + data => $objects + }); + } + + foreach my $object (@$objects) { + $object = $class->new_from_hash($object); + } + return $objects; } ############################### @@ -392,6 +538,13 @@ sub update { $self->audit_log(\%changes) if $self->AUDIT_UPDATES; $dbh->bz_commit_transaction(); + if ($self->USE_MEMCACHED && @values) { + Bugzilla->memcached->clear({ table => $table, id => $self->id }); + Bugzilla->memcached->clear_config() + if $self->IS_CONFIG; + } + $self->_object_cache_remove({ id => $self->id }); + $self->_object_cache_remove({ name => $self->name }) if $self->name; if (wantarray) { return (\%changes, $old_self); @@ -410,6 +563,13 @@ 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(); + if ($self->USE_MEMCACHED) { + Bugzilla->memcached->clear({ table => $table, id => $self->id }); + Bugzilla->memcached->clear_config() + if $self->IS_CONFIG; + } + $self->_object_cache_remove({ id => $self->id }); + $self->_object_cache_remove({ name => $self->name }) if $self->name; undef $self; } @@ -444,6 +604,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 ###### ############################### @@ -467,6 +634,13 @@ sub create { my $object = $class->insert_create_data($field_values); $dbh->bz_commit_transaction(); + if (Bugzilla->memcached->enabled + && $class->USE_MEMCACHED + && $class->IS_CONFIG) + { + Bugzilla->memcached->clear_config(); + } + return $object; } @@ -562,7 +736,7 @@ sub insert_create_data { sub get_all { my $class = shift; - return @{$class->_do_list_select()}; + return @{ $class->_do_list_select() }; } ############################### @@ -971,6 +1145,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 @@ -1010,6 +1195,13 @@ template. Returns: A reference to an array of objects. +=item C<new_from_hash($hashref)> + + Description: Create an object from the given hash. + + Params: $hashref - A reference to a hash which was created by + flatten_to_hash. + =item C<match> =over @@ -1247,6 +1439,17 @@ that should be passed to the C<set_> function that is called. =back +=head2 Simple Methods + +=over + +=item C<flatten_to_hash> + +Returns a hashref suitable for serialisation and re-inflation with C<new_from_hash>. + +=back + + =head2 Simple Validators You can use these in your subclass L</VALIDATORS> or L</UPDATE_VALIDATORS>. @@ -1261,6 +1464,58 @@ Returns C<1> if the passed-in value is true, C<0> otherwise. =back +=head2 CACHE FUNCTIONS + +=over + +=item C<object_cache_get> + +=over + +=item B<Description> + +Class function which returns an object from the object-cache for the provided +C<$id>. + +=item B<Params> + +Takes an integer C<$id> of the object to retrieve. + +=item B<Returns> + +Returns the object from the cache if found, otherwise returns C<undef>. + +=item B<Example> + +my $bug_from_cache = Bugzilla::Bug->object_cache_get(35); + +=back + +=item C<object_cache_set> + +=over + +=item B<Description> + +Object function which injects the object into the object-cache, using the +object's C<id> as the key. + +=item B<Params> + +(none) + +=item B<Returns> + +(nothing) + +=item B<Example> + +$bug->object_cache_set(); + +=back + +=back + =head1 CLASS FUNCTIONS =over @@ -1285,3 +1540,19 @@ C<0> otherwise. =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item object_cache_key + +=item check_time + +=item id + +=item TO_JSON + +=item audit_log + +=back diff --git a/Bugzilla/Product.pm b/Bugzilla/Product.pm index fdebc6b00..30ebc7c6c 100644 --- a/Bugzilla/Product.pm +++ b/Bugzilla/Product.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Product; + +use 5.10.1; use strict; -use base qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object); +use warnings; + +use parent qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object); use Bugzilla::Constants; use Bugzilla::Util; @@ -31,6 +35,8 @@ use constant DEFAULT_CLASSIFICATION_ID => 1; #### Initialization #### ############################### +use constant IS_CONFIG => 1; + use constant DB_TABLE => 'products'; use constant DB_COLUMNS => qw( @@ -97,6 +103,7 @@ sub create { Bugzilla::Hook::process('product_end_of_create', { product => $product }); $dbh->bz_commit_transaction(); + Bugzilla->memcached->clear_config(); return $product; } @@ -253,6 +260,7 @@ sub update { # Changes have been committed. delete $self->{check_group_controls}; Bugzilla->user->clear_product_cache(); + Bugzilla->memcached->clear_config(); return $changes; } @@ -270,8 +278,8 @@ sub remove_from_db { if (Bugzilla->params->{'allowbugdeletion'}) { require Bugzilla::Bug; foreach my $bug_id (@{$self->bug_ids}) { - # Note that we allow the user to delete bugs he can't see, - # which is okay, because he's deleting the whole Product. + # Note that we allow the user to delete bugs they can't see, + # which is okay, because they're deleting the whole Product. my $bug = new Bugzilla::Bug($bug_id); $bug->remove_from_db(); } @@ -311,6 +319,7 @@ sub remove_from_db { $self->SUPER::remove_from_db(); $dbh->bz_commit_transaction(); + Bugzilla->memcached->clear_config(); # We have to delete these internal variables, else we get # the old lists of products and classifications again. @@ -807,8 +816,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'}; } @@ -1022,7 +1031,7 @@ a group is valid in a particular product.) Params: C<$user> - A Bugzilla::User object. - Returns C<1> If this user's groups allow him C<entry> access to + Returns C<1> If this user's groups allow them C<entry> access to this Product, C<0> otherwise. =item C<flag_types()> @@ -1067,3 +1076,37 @@ C<Bugzilla::Product::preload($products)>. L<Bugzilla::Object> =cut + +=head1 B<Methods in need of POD> + +=over + +=item set_allows_unconfirmed + +=item allows_unconfirmed + +=item set_name + +=item set_default_milestone + +=item set_group_controls + +=item create + +=item set_description + +=item set_is_active + +=item classification_id + +=item description + +=item default_milestone + +=item remove_from_db + +=item is_active + +=item update + +=back diff --git a/Bugzilla/RNG.pm b/Bugzilla/RNG.pm index 457d2cae0..96e442fa0 100644 --- a/Bugzilla/RNG.pm +++ b/Bugzilla/RNG.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::RNG; + +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent qw(Exporter); use Bugzilla::Constants qw(ON_WINDOWS); use Math::Random::ISAAC; @@ -216,3 +220,15 @@ sub _win2k_seed { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item srand + +=item rand + +=item irand + +=back diff --git a/Bugzilla/Report.pm b/Bugzilla/Report.pm index 4c9f33226..10af2ea9e 100644 --- a/Bugzilla/Report.pm +++ b/Bugzilla/Report.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Report; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::CGI; use Bugzilla::Constants; @@ -132,3 +134,17 @@ of L<Bugzilla::Object>, and thus provides all methods that L<Bugzilla::Object> provides. =cut + +=head1 B<Methods in need of POD> + +=over + +=item create + +=item query + +=item set_query + +=item set_name + +=back diff --git a/Bugzilla/Search.pm b/Bugzilla/Search.pm index d67df03dd..8097d5fb8 100644 --- a/Bugzilla/Search.pm +++ b/Bugzilla/Search.pm @@ -5,10 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. +package Bugzilla::Search; + +use 5.10.1; use strict; +use warnings; -package Bugzilla::Search; -use base qw(Exporter); +use parent qw(Exporter); @Bugzilla::Search::EXPORT = qw( IsValidQueryType split_order_term @@ -107,6 +110,7 @@ use Time::HiRes qw(gettimeofday tv_interval); # 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. @@ -157,6 +161,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 @@ -183,6 +189,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 }; @@ -198,6 +206,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, @@ -251,7 +265,7 @@ use constant OPERATOR_FIELD_OVERRIDE => { }, # General Bug Fields - alias => { _non_changed => \&_nullable }, + alias => { _non_changed => \&_alias_nonchanged }, 'attach_data.thedata' => MULTI_SELECT_OVERRIDE, # We check all attachment fields against this. attachments => MULTI_SELECT_OVERRIDE, @@ -303,7 +317,8 @@ use constant OPERATOR_FIELD_OVERRIDE => { _non_changed => \&_product_nonchanged, }, tag => MULTI_SELECT_OVERRIDE, - + comment_tag => MULTI_SELECT_OVERRIDE, + # Timetracking Fields deadline => { _non_changed => \&_deadline }, percentage_complete => { @@ -315,11 +330,16 @@ use constant OPERATOR_FIELD_OVERRIDE => { changedafter => \&_work_time_changedbefore_after, _default => \&_work_time, }, + last_visit_ts => { + _non_changed => \&_last_visit_ts, + _default => \&_last_visit_ts_invalid_operator, + }, # Custom Fields 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, @@ -340,13 +360,19 @@ sub SPECIAL_PARSING { 'requestees.login_name' => \&_contact_pronoun, # Date Fields that accept the 1d, 1w, 1m, 1y, etc. format. - creation_ts => \&_timestamp_translate, - deadline => \&_timestamp_translate, - delta_ts => \&_timestamp_translate, + creation_ts => \&_datetime_translate, + deadline => \&_date_translate, + delta_ts => \&_datetime_translate, + + # last_visit field that accept both a 1d, 1w, 1m, 1y format and the + # %last_changed% pronoun. + last_visit_ts => \&_last_visit_datetime, }; foreach my $field (Bugzilla->active_custom_fields) { if ($field->type == FIELD_TYPE_DATETIME) { - $map->{$field->name} = \&_timestamp_translate; + $map->{$field->name} = \&_datetime_translate; + } elsif ($field->type == FIELD_TYPE_DATE) { + $map->{$field->name} = \&_date_translate; } } return $map; @@ -391,6 +417,7 @@ use constant FIELD_MAP => { bugidtype => 'bug_id_type', changedin => 'days_elapsed', long_desc => 'longdesc', + tags => 'tag', }; # Some fields are not sorted on themselves, but on other fields. @@ -429,6 +456,10 @@ sub COLUMN_JOINS { . ' FROM longdescs GROUP BY bug_id)', join => 'INNER', }, + alias => { + table => 'bugs_aliases', + as => 'map_alias', + }, assigned_to => { from => 'assigned_to', to => 'userid', @@ -484,6 +515,14 @@ sub COLUMN_JOINS { to => 'id', }, }, + blocked => { + table => 'dependencies', + to => 'dependson', + }, + dependson => { + table => 'dependencies', + to => 'blocked', + }, 'longdescs.count' => { table => 'longdescs', join => 'INNER', @@ -498,7 +537,14 @@ sub COLUMN_JOINS { from => 'map_bug_tag.tag_id', to => 'id', }, - } + }, + last_visit_ts => { + as => 'bug_user_last_visit', + table => 'bug_user_last_visit', + extra => ['bug_user_last_visit.user_id = ' . $user->id], + from => 'bug_id', + to => 'bug_id', + }, }; return $joins; }; @@ -544,6 +590,7 @@ sub COLUMNS { # like "bugs.bug_id". my $total_time = "(map_actual_time.total + bugs.remaining_time)"; my %special_sql = ( + alias => $dbh->sql_group_concat('DISTINCT map_alias.alias'), deadline => $dbh->sql_date_format('bugs.deadline', '%Y-%m-%d'), actual_time => 'map_actual_time.total', @@ -558,13 +605,18 @@ sub COLUMNS { . " END)", 'flagtypes.name' => $dbh->sql_group_concat('DISTINCT ' - . $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status')), + . $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status'), + undef, undef, 'map_flagtypes.sortkey, map_flagtypes.name'), '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)', tag => $dbh->sql_group_concat('DISTINCT map_tag.name'), + last_visit_ts => 'bug_user_last_visit.last_visit_ts', ); # Backward-compatibility for old field names. Goes new_name => old_name. @@ -635,12 +687,7 @@ sub REPORT_COLUMNS { # or simply don't work with the current reporting system. my @no_report_columns = qw(bug_id alias short_short_desc opendate changeddate - flagtypes.name keywords relevance); - - # Multi-select fields are not currently supported. - my @multi_selects = @{Bugzilla->fields( - { obsolete => 0, type => FIELD_TYPE_MULTI_SELECT })}; - push(@no_report_columns, map { $_->name } @multi_selects); + flagtypes.name relevance); # If you're not a time-tracker, you can't use time-tracking # columns. @@ -658,7 +705,10 @@ 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( + alias + blocked bug_id + dependson flagtypes.name keywords longdescs.count @@ -712,7 +762,7 @@ sub data { 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+$/; + next if ($self->COLUMNS->{$field}->{name} // $field) =~ /^bugs\.\w+$/; $self->{fields} = ['bug_id']; $all_in_bugs_table = 0; last; @@ -964,10 +1014,16 @@ sub _sql_select { my ($self) = @_; my @sql_fields; foreach my $column ($self->_display_columns) { - my $alias = $column; - # Aliases cannot contain dots in them. We convert them to underscores. - $alias =~ s/\./_/g; - my $sql = $self->COLUMNS->{$column}->{name} . " AS $alias"; + my $sql = $self->COLUMNS->{$column}->{name} // ''; + if ($sql) { + my $alias = $column; + # Aliases cannot contain dots in them. We convert them to underscores. + $alias =~ tr/./_/; + $sql .= " AS $alias"; + } + else { + $sql = $column; + } push(@sql_fields, $sql); } return @sql_fields; @@ -1210,9 +1266,12 @@ sub _standard_joins { push(@joins, $security_join); if ($user->id) { - $security_join->{extra} = - ["NOT (" . $user->groups_in_sql('security_map.group_id') . ")"]; - + # See also _standard_joins for the other half of the below statement + if (!Bugzilla->params->{'or_groups'}) { + $security_join->{extra} = + ["NOT (" . $user->groups_in_sql('security_map.group_id') . ")"]; + } + my $security_cc_join = { table => 'cc', as => 'security_cc', @@ -1286,10 +1345,17 @@ sub _standard_where { # until their group controls are set. So if a bug has a NULL creation_ts, # it shouldn't show up in searches at all. my @where = ('bugs.creation_ts IS NOT NULL'); - - my $security_term = 'security_map.group_id IS NULL'; my $user = $self->_user; + my $security_term = ''; + # See also _standard_joins for the other half of the below statement + if (Bugzilla->params->{'or_groups'}) { + $security_term .= " (security_map.group_id IS NULL OR security_map.group_id IN (" . $user->groups_as_string . "))"; + } + else { + $security_term = 'security_map.group_id IS NULL'; + } + if ($user->id) { my $userid = $user->id; # This indentation makes the resulting SQL more readable. @@ -1334,7 +1400,7 @@ sub _sql_group_by { my @extra_group_by; foreach my $column ($self->_select_columns) { next if $self->_skip_group_by->{$column}; - my $sql = $self->COLUMNS->{$column}->{name}; + my $sql = $self->COLUMNS->{$column}->{name} // $column; push(@extra_group_by, $sql); } @@ -1536,9 +1602,8 @@ sub _special_parse_chfield { sub _special_parse_deadline { my ($self) = @_; - return if !$self->_user->is_timetracker; my $params = $self->_params; - + my $clause = new Bugzilla::Search::Clause(); if (my $from = $params->{'deadlinefrom'}) { $clause->add('deadline', 'greaterthaneq', $from); @@ -1680,6 +1745,8 @@ sub _boolean_charts { my $field = $params->{"field$identifier"}; my $operator = $params->{"type$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); @@ -1726,6 +1793,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); @@ -1755,20 +1824,30 @@ sub _handle_chart { my ($field, $operator, $value) = $condition->fov; return if (!defined $field or !defined $operator or !defined $value); $field = FIELD_MAP->{$field} || $field; - - my $string_value; + + my ($string_value, $orig_value); + state $is_mysql = $dbh->isa('Bugzilla::DB::Mysql') ? 1 : 0; + if (ref $value eq 'ARRAY') { # Trim input and ignore blank values. @$value = map { trim($_) } @$value; @$value = grep { defined $_ and $_ ne '' } @$value; return if !@$value; + $orig_value = join(',', @$value); + if ($field eq 'longdesc' && $is_mysql) { + @$value = map { _convert_unicode_characters($_) } @$value; + } $string_value = join(',', @$value); } else { return if $value eq ''; + $orig_value = $value; + if ($field eq 'longdesc' && $is_mysql) { + $value = _convert_unicode_characters($value); + } $string_value = $value; } - + $self->_chart_fields->{$field} or ThrowCodeError("invalid_field_name", { field => $field }); trick_taint($field); @@ -1812,7 +1891,7 @@ sub _handle_chart { # do_search_function modified them. $self->search_description({ field => $field, type => $operator, - value => $string_value, term => $search_args{term}, + value => $orig_value, term => $search_args{term}, }); foreach my $join (@{ $search_args{joins} }) { @@ -1823,6 +1902,18 @@ sub _handle_chart { $condition->translated(\%search_args); } +# XXX - This is a hack for MySQL which doesn't understand Unicode characters +# above U+FFFF, see Bugzilla::Comment::_check_thetext(). This hack can go away +# once we require MySQL 5.5.3 and use utf8mb4. +sub _convert_unicode_characters { + my $string = shift; + + # Perl 5.13.8 and older complain about non-characters. + no warnings 'utf8'; + $string =~ s/([\x{10000}-\x{10FFFF}])/"\x{FDD0}[" . uc(sprintf('U+%04x', ord($1))) . "]\x{FDD1}"/eg; + return $string; +} + ################################## # do_search_function And Helpers # ################################## @@ -2075,22 +2166,44 @@ 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 _last_visit_datetime { + my ($self, $args) = @_; + my $value = $args->{value}; + + $self->_datetime_translate($args); + if ($value eq $args->{value}) { + # Failed to translate a datetime. let's try the pronoun expando. + if ($value eq '%last_changed%') { + $self->_add_extra_column('changeddate'); + $args->{value} = $args->{quoted} = 'bugs.delta_ts'; + } + } +} + + +sub _date_translate { + return shift->_timestamp_translate(1, @_); +} + sub SqlifyDate { my ($str) = @_; my $fmt = "%Y-%m-%d %H:%M:%S"; @@ -2178,7 +2291,8 @@ sub pronoun { if ($noun eq "%qacontact%") { return "COALESCE(bugs.qa_contact,0)"; } - return 0; + + ThrowUserError('illegal_pronoun', { pronoun => $noun }); } sub _contact_pronoun { @@ -2354,7 +2468,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); } @@ -2442,6 +2556,11 @@ sub _long_desc_nonchanged { my ($self, $args) = @_; 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"; @@ -2583,6 +2702,21 @@ sub _percentage_complete { $self->_add_extra_column('actual_time'); } +sub _last_visit_ts { + my ($self, $args) = @_; + + $args->{full_field} = $self->COLUMNS->{last_visit_ts}->{name}; + $self->_add_extra_column('last_visit_ts'); +} + +sub _last_visit_ts_invalid_operator { + my ($self, $args) = @_; + + ThrowUserError('search_field_operator_invalid', + { field => $args->{field}, + operator => $args->{operator} }); +} + sub _days_elapsed { my ($self, $args) = @_; my $dbh = Bugzilla->dbh; @@ -2612,6 +2746,15 @@ sub _product_nonchanged { "products.id", "products", $term); } +sub _alias_nonchanged { + my ($self, $args) = @_; + + $args->{full_field} = "bugs_aliases.alias"; + $self->_do_operator_function($args); + $args->{term} = build_subselect("bugs.bug_id", + "bugs_aliases.bug_id", "bugs_aliases", $args->{term}); +} + sub _classification_nonchanged { my ($self, $args) = @_; my $joins = $args->{joins}; @@ -2646,6 +2789,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}; @@ -2734,6 +2884,12 @@ sub _flagtypes_nonchanged { my ($self, $args) = @_; 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. @@ -2838,10 +2994,12 @@ sub _multiselect_table { return "attachments INNER JOIN attach_data " . " ON attachments.attach_id = attach_data.id" } - elsif ($field eq 'flagtypes.name') { - $args->{full_field} = $dbh->sql_string_concat("flagtypes.name", - "flags.status"); - return "flags INNER JOIN flagtypes ON flags.type_id = flagtypes.id"; + elsif ($field eq 'comment_tag') { + $args->{_extra_where} = " AND longdescs.isprivate = 0" + if !$self->_user->is_insider; + $args->{full_field} = 'longdescs_tags.tag'; + return "longdescs INNER JOIN longdescs_tags". + " ON longdescs.comment_id = longdescs_tags.comment_id"; } my $table = "bug_$field"; $args->{full_field} = "bug_$field.value"; @@ -2850,6 +3008,11 @@ sub _multiselect_table { sub _multiselect_term { my ($self, $args, $not) = @_; + my ($operator) = $args->{operator}; + my $value = $args->{value} || ''; + # 'empty' operators require special handling + return $self->_multiselect_isempty($args, $not) + if ($operator =~ /^is(not)?empty$/ || $value eq '---'); my $table = $self->_multiselect_table($args); $self->_do_operator_function($args); my $term = $args->{term}; @@ -2858,6 +3021,125 @@ sub _multiselect_term { 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->_reverse_operator($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"; + } + elsif ($self->_multi_select_fields->{$field}) { + push @$joins, { + table => "bug_$field", + as => "bug_${field}_$chart_id", + from => 'bug_id', + to => 'bug_id', + }; + return "bug_${field}_$chart_id.bug_id IS $not NULL"; + } +} + ############################### # Standard Operator Functions # ############################### @@ -3074,6 +3356,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 # ###################### @@ -3177,7 +3480,7 @@ value for this field. At least one search criteria must be defined if the =item C<sharer> -When a saved search is shared by a user, this is his user ID. +When a saved search is shared by a user, this is their user ID. =item C<user> @@ -3228,3 +3531,35 @@ two hashes if two SQL queries have been executed sequentially to get all the required data. =back + +=head1 B<Methods in need of POD> + +=over + +=item invalid_order_columns + +=item COLUMN_JOINS + +=item split_order_term + +=item SqlifyDate + +=item REPORT_COLUMNS + +=item pronoun + +=item COLUMNS + +=item order + +=item search_description + +=item IsValidQueryType + +=item build_subselect + +=item do_search_function + +=item boolean_charts_to_custom_search + +=back diff --git a/Bugzilla/Search/Clause.pm b/Bugzilla/Search/Clause.pm index 6214ab197..1d7872c78 100644 --- a/Bugzilla/Search/Clause.pm +++ b/Bugzilla/Search/Clause.pm @@ -6,7 +6,10 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Search::Clause; + +use 5.10.1; use strict; +use warnings; use Bugzilla::Error; use Bugzilla::Search::Condition qw(condition); @@ -131,3 +134,27 @@ sub as_params { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item has_translated_conditions + +=item as_string + +=item add + +=item children + +=item negate + +=item update_search_args + +=item walk_conditions + +=item joiner + +=item as_params + +=back diff --git a/Bugzilla/Search/ClauseGroup.pm b/Bugzilla/Search/ClauseGroup.pm index 83961e12b..590c737fa 100644 --- a/Bugzilla/Search/ClauseGroup.pm +++ b/Bugzilla/Search/ClauseGroup.pm @@ -7,9 +7,11 @@ package Bugzilla::Search::ClauseGroup; +use 5.10.1; use strict; +use warnings; -use base qw(Bugzilla::Search::Clause); +use parent qw(Bugzilla::Search::Clause); use Bugzilla::Error; use Bugzilla::Search::Condition qw(condition); @@ -97,3 +99,13 @@ sub update_search_args { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item add + +=item update_search_args + +=back diff --git a/Bugzilla/Search/Condition.pm b/Bugzilla/Search/Condition.pm index fb899afbc..306a63eed 100644 --- a/Bugzilla/Search/Condition.pm +++ b/Bugzilla/Search/Condition.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Search::Condition; + +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent qw(Exporter); our @EXPORT_OK = qw(condition); sub new { @@ -73,3 +77,27 @@ sub condition { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item as_string + +=item fov + +=item value + +=item negate + +=item translated + +=item operator + +=item as_params + +=item condition + +=item field + +=back diff --git a/Bugzilla/Search/Quicksearch.pm b/Bugzilla/Search/Quicksearch.pm index 3e8340c86..830177f8b 100644 --- a/Bugzilla/Search/Quicksearch.pm +++ b/Bugzilla/Search/Quicksearch.pm @@ -7,8 +7,9 @@ package Bugzilla::Search::Quicksearch; -# Make it harder for us to do dangerous things in Perl. +use 5.10.1; use strict; +use warnings; use Bugzilla::Error; use Bugzilla::Constants; @@ -21,7 +22,7 @@ use List::Util qw(min max); use List::MoreUtils qw(firstidx); use Text::ParseWords qw(parse_line); -use base qw(Exporter); +use parent qw(Exporter); @Bugzilla::Search::Quicksearch::EXPORT = qw(quicksearch); # Custom mappings for some fields. @@ -103,6 +104,17 @@ use constant FIELD_OPERATOR => { owner_idle_time => 'greaterthan', }; +# Mappings for operators symbols to support operators other than "substring" +use constant OPERATOR_SYMBOLS => { + ':' => 'substring', + '=' => 'equals', + '!=' => 'notequals', + '>=' => 'greaterthaneq', + '<=' => 'lessthaneq', + '>' => 'greaterthan', + '<' => 'lessthan', +}; + # We might want to put this into localconfig or somewhere use constant PRODUCT_EXCEPTIONS => ( 'row', # [Browser] @@ -196,6 +208,7 @@ sub quicksearch { foreach my $qsword (@qswords) { 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); @@ -262,6 +275,8 @@ sub 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 @@ -276,7 +291,7 @@ sub _parse_line { my @words = parse_line($delim, $keep, $line); foreach my $word (@words) { - $word =~ tr/\000/'/; + $word =~ tr/\000/'/ if defined $word; } return @words; } @@ -287,7 +302,7 @@ sub _bug_numbers_only { # 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"); @@ -307,9 +322,10 @@ sub _handle_alias { my $alias = $1; # We use this direct SQL because we want quicksearch to be VERY fast. my $bug_id = Bugzilla->dbh->selectrow_array( - q{SELECT bug_id FROM bugs WHERE alias = ?}, undef, $alias); - # If the user cannot see the bug, do not resolve its alias. - if ($bug_id && Bugzilla->user->can_see_bug($bug_id)) { + q{SELECT bug_id FROM bugs_aliases 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"); @@ -348,6 +364,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); @@ -386,8 +403,13 @@ sub _handle_field_names { # 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); - if (scalar @field_values == 2) { + # Longer operators must be tested first as we don't want single character + # operators such as <, > and = to be tested before <=, >= and !=. + my @operators = sort { length($b) <=> length($a) } keys %{ OPERATOR_SYMBOLS() }; + + foreach my $symbol (@operators) { + my @field_values = _parse_line($symbol, 1, $or_operand); + next unless scalar @field_values == 2; my @fields = _parse_line(',', 1, $field_values[0]); my @values = _parse_line(',', 1, $field_values[1]); foreach my $field (@fields) { @@ -406,7 +428,9 @@ sub _handle_field_names { $bug_status_set = 1; } foreach my $value (@values) { - my $operator = FIELD_OPERATOR->{$translated} || 'substring'; + my $operator = FIELD_OPERATOR->{$translated} + || OPERATOR_SYMBOLS->{$symbol} + || 'substring'; # If the string was quoted to protect some special # characters such as commas and colons, we need # to remove quotes. @@ -448,7 +472,7 @@ sub _handle_flags { # are unable to run queries of the form (a AND b) OR c. In our case: # (flag name is foo AND requestee is bar) OR (any other criteria). # But this has never been possible, so this is not a regression. If one - # needs to run such queries, he must use the Custom Search section of + # needs to run such queries, they must use the Custom Search section of # the Advanced Search page. $chart++; $and = $or = 0; @@ -654,3 +678,21 @@ sub makeChart { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item FIELD_MAP + +=item quicksearch + +=item negateComparisonType + +=item makeChart + +=item addChart + +=item matchPrefixes + +=back diff --git a/Bugzilla/Search/Recent.pm b/Bugzilla/Search/Recent.pm index 1ff56c7e3..e774c7fe0 100644 --- a/Bugzilla/Search/Recent.pm +++ b/Bugzilla/Search/Recent.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Search::Recent; + +use 5.10.1; use strict; -use base qw(Bugzilla::Object); +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::Constants; use Bugzilla::Error; @@ -39,6 +43,9 @@ use constant VALIDATORS => { use constant UPDATE_COLUMNS => qw(bug_list list_order); +# There's no gain to caching these objects +use constant USE_MEMCACHED => 0; + ################### # DB Manipulation # ################### @@ -157,3 +164,27 @@ Bugzilla::Search::Recent - A search recently run by a logged-in user. This is an implementation of L<Bugzilla::Object>, and so has all the same methods available as L<Bugzilla::Object>, in addition to what is documented below. + +=head1 B<Methods in need of POD> + +=over + +=item create + +=item list_order + +=item check_quietly + +=item new_from_cookie + +=item create_placeholder + +=item bug_list + +=item set_bug_list + +=item user_id + +=item set_list_order + +=back diff --git a/Bugzilla/Search/Saved.pm b/Bugzilla/Search/Saved.pm index c9885c9ce..50a9cdd67 100644 --- a/Bugzilla/Search/Saved.pm +++ b/Bugzilla/Search/Saved.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Search::Saved; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::CGI; use Bugzilla::Constants; @@ -186,6 +188,7 @@ sub rename_field_value { } $dbh->do("UPDATE $table SET query = ? WHERE $id_field = ?", undef, $query, $id); + Bugzilla->memcached->clear({ table => $table, id => $id }); } $dbh->bz_commit_transaction(); @@ -288,9 +291,8 @@ sub url { return $_[0]->{'query'}; } sub user { my ($self) = @_; - return $self->{user} if defined $self->{user}; - $self->{user} = new Bugzilla::User($self->{userid}); - return $self->{user}; + return $self->{user} ||= + Bugzilla::User->new({ id => $self->{userid}, cache => 1 }); } ############ @@ -385,3 +387,21 @@ Returns how many users (besides the author of the saved search) are using the saved search, i.e. have it displayed in their footer. =back + +=head1 B<Methods in need of POD> + +=over + +=item create + +=item set_name + +=item set_url + +=item rename_field_value + +=item user + +=item used_in_whine + +=back diff --git a/Bugzilla/Send/Sendmail.pm b/Bugzilla/Sender/Transport/Sendmail.pm index 012cd6f28..49f00777f 100644 --- a/Bugzilla/Send/Sendmail.pm +++ b/Bugzilla/Sender/Transport/Sendmail.pm @@ -5,56 +5,49 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -package Bugzilla::Send::Sendmail; +package Bugzilla::Sender::Transport::Sendmail; +use 5.10.1; use strict; +use warnings; -use base qw(Email::Send::Sendmail); +use parent qw(Email::Sender::Transport::Sendmail); -use Return::Value; -use Symbol qw(gensym); +use Email::Sender::Failure; -sub send { - my ($class, $message, @args) = @_; - my $mailer = $class->_find_sendmail; +sub send_email { + my ($self, $email, $envelope) = @_; - return failure "Couldn't find 'sendmail' executable in your PATH" - ." and Email::Send::Sendmail::SENDMAIL is not set" - unless $mailer; + my $pipe = $self->_sendmail_pipe($envelope); - return failure "Found $mailer but cannot execute it" - unless -x $mailer; - - local $SIG{'CHLD'} = 'DEFAULT'; + my $string = $email->as_string; + $string =~ s/\x0D\x0A/\x0A/g unless $^O eq 'MSWin32'; - my $pipe = gensym; + print $pipe $string + or Email::Sender::Failure->throw("couldn't send message to sendmail: $!"); - 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 $!; + Email::Sender::Failure->throw("error when closing pipe to sendmail: $!") 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 + # remove them from the queue. if ($is_transient) { - return failure "error when closing pipe to $mailer: $error_message"; + Email::Sender::Failure->throw("error when closing pipe to sendmail: $error_message"); } else { - warn "error when closing pipe to $mailer: $error_message\n"; - return success; + warn "error when closing pipe to sendmail: $error_message\n"; + return $self->success; } } else { - return failure "error when closing pipe to $mailer: $error_message"; + Email::Sender::Failure->throw("error when closing pipe to sendmail: $error_message"); } } - return success; + return $self->success; } sub _map_exitcode { # Returns (error message, is_transient) - # from the sendmail source (sendmail/sysexit.h) + # from the sendmail source (sendmail/sysexits.h) my $code = shift; if ($code == 64) { return ("Command line usage error (EX_USAGE)", 1); @@ -93,3 +86,10 @@ sub _map_exitcode { 1; +=head1 B<Methods in need of POD> + +=over + +=item send_email + +=back diff --git a/Bugzilla/Series.pm b/Bugzilla/Series.pm index f2ed9a4af..22202c6f1 100644 --- a/Bugzilla/Series.pm +++ b/Bugzilla/Series.pm @@ -5,8 +5,6 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - # This module implements a series - a set of data to be plotted on a chart. # # This Series is in the database if and only if self->{'series_id'} is defined. @@ -16,6 +14,10 @@ use strict; package Bugzilla::Series; +use 5.10.1; +use strict; +use warnings; + use Bugzilla::Error; use Bugzilla::Util; @@ -269,3 +271,29 @@ sub remove_from_db { } 1; + +=head1 B<Methods in need of POD> + +=over + +=item creator + +=item existsInDatabase + +=item name + +=item getCategoryID + +=item initFromParameters + +=item initFromCGI + +=item initFromDatabase + +=item remove_from_db + +=item writeToDatabase + +=item id + +=back diff --git a/Bugzilla/Status.pm b/Bugzilla/Status.pm index 2821f7c6e..275510216 100644 --- a/Bugzilla/Status.pm +++ b/Bugzilla/Status.pm @@ -5,16 +5,17 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Status; -use Bugzilla::Error; +use 5.10.1; +use strict; +use warnings; + # This subclasses Bugzilla::Field::Choice instead of implementing # ChoiceInterface, because a bug status literally is a special type # of Field::Choice, not just an object that happens to have the same # methods. -use base qw(Bugzilla::Field::Choice Exporter); +use parent qw(Bugzilla::Field::Choice Exporter); @Bugzilla::Status::EXPORT = qw( BUG_STATE_OPEN SPECIAL_STATUS_WORKFLOW_ACTIONS @@ -23,6 +24,8 @@ use base qw(Bugzilla::Field::Choice Exporter); closed_bug_statuses ); +use Bugzilla::Error; + ################################ ##### Initialization ##### ################################ @@ -106,11 +109,21 @@ sub _check_value { sub BUG_STATE_OPEN { my $dbh = Bugzilla->dbh; - my $cache = Bugzilla->request_cache; - $cache->{status_bug_state_open} ||= - $dbh->selectcol_arrayref('SELECT value FROM bug_status - WHERE is_open = 1'); - return @{ $cache->{status_bug_state_open} }; + my $request_cache = Bugzilla->request_cache; + my $cache_key = 'status_bug_state_open'; + return @{ $request_cache->{$cache_key} } + if exists $request_cache->{$cache_key}; + + my $rows = Bugzilla->memcached->get_config({ key => $cache_key }); + if (!$rows) { + $rows = $dbh->selectcol_arrayref( + 'SELECT value FROM bug_status WHERE is_open = 1' + ); + Bugzilla->memcached->set_config({ key => $cache_key, data => $rows }); + } + + $request_cache->{$cache_key} = $rows; + return @$rows; } # Tells you whether or not the argument is a valid "open" state. @@ -295,3 +308,27 @@ C<1> if a comment is required on this change, C<0> if not. =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item create + +=item BUG_STATE_OPEN + +=item is_static + +=item is_open_state + +=item is_active + +=item remove_from_db + +=item DB_COLUMNS + +=item is_open + +=item VALIDATORS + +=back diff --git a/Bugzilla/Template.pm b/Bugzilla/Template.pm index acfc5a50f..6ac36f783 100644 --- a/Bugzilla/Template.pm +++ b/Bugzilla/Template.pm @@ -8,7 +8,9 @@ package Bugzilla::Template; +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; use Bugzilla::WebService::Constants; @@ -16,6 +18,7 @@ use Bugzilla::Hook; use Bugzilla::Install::Requirements; use Bugzilla::Install::Util qw(install_string template_include_path include_languages); +use Bugzilla::Classification; use Bugzilla::Keyword; use Bugzilla::Util; use Bugzilla::Error; @@ -25,15 +28,17 @@ use Bugzilla::Token; use Cwd qw(abs_path); use MIME::Base64; use Date::Format (); +use Digest::MD5 qw(md5_hex); use File::Basename qw(basename dirname); use File::Find; use File::Path qw(rmtree mkpath); +use File::Slurp; use File::Spec; use IO::Dir; use List::MoreUtils qw(firstidx); use Scalar::Util qw(blessed); -use base qw(Template); +use parent qw(Template); use constant FORMAT_TRIPLE => '%19s|%-28s|%-28s'; use constant FORMAT_3_SIZE => [19,28,28]; @@ -95,8 +100,8 @@ sub get_format { my $self = shift; my ($template, $format, $ctype) = @_; - $ctype ||= 'html'; - $format ||= ''; + $ctype //= 'html'; + $format //= ''; # ctype and format can have letters and a hyphen only. if ($ctype =~ /[^a-zA-Z\-]/ || $format =~ /[^a-zA-Z\-]/) { @@ -157,6 +162,10 @@ sub quoteUrls { # until we require Perl 5.13.9 or newer. no warnings 'utf8'; + # If the comment is already wrapped, we should ignore newlines when + # looking for matching regexps. Else we should take them into account. + my $s = ($comment && $comment->already_wrapped) ? qr/\s/ : qr/\h/; + # However, note that adding the title (for buglinks) can affect things # In particular, attachment matches go before bug titles, so that titles # with 'attachment 1' don't double match. @@ -223,7 +232,7 @@ sub quoteUrls { ~<a href=\"mailto:$2\">$1$2</a>~igx; # attachment links - $text =~ s~\b(attachment\s*\#?\s*(\d+)(?:\s+\[details\])?) + $text =~ s~\b(attachment$s*\#?$s*(\d+)(?:$s+\[details\])?) ~($things[$count++] = get_attachment_link($2, $1, $user)) && ("\x{FDD2}" . ($count-1) . "\x{FDD3}") ~egmxi; @@ -236,14 +245,40 @@ sub quoteUrls { # Also, we can't use $bug_re?$comment_re? because that will match the # empty string my $bug_word = template_var('terms')->{bug}; - my $bug_re = qr/\Q$bug_word\E\s*\#?\s*(\d+)/i; - my $comment_re = qr/comment\s*\#?\s*(\d+)/i; - $text =~ s~\b($bug_re(?:\s*,?\s*$comment_re)?|$comment_re) + my $bug_re = qr/\Q$bug_word\E$s*\#?$s*(\d+)/i; + my $comment_word = template_var('terms')->{comment}; + my $comment_re = qr/(?:\Q$comment_word\E|comment)$s*\#?$s*(\d+)/i; + $text =~ s~\b($bug_re(?:$s*,?$s*$comment_re)?|$comment_re) ~ # We have several choices. $1 here is the link, and $2-4 are set # depending on which part matched (defined($2) ? get_bug_link($2, $1, { comment_num => $3, user => $user }) : "<a href=\"$current_bugurl#c$4\">$1</a>") - ~egox; + ~egx; + + # Handle a list of bug ids: bugs 1, #2, 3, 4 + # Currently, the only delimiter supported is comma. + # Concluding "and" and "or" are not supported. + my $bugs_word = template_var('terms')->{bugs}; + + my $bugs_re = qr/\Q$bugs_word\E$s*\#?$s* + \d+(?:$s*,$s*\#?$s*\d+)+/ix; + + $text =~ s{($bugs_re)}{ + my $match = $1; + $match =~ s/((?:#$s*)?(\d+))/get_bug_link($2, $1);/eg; + $match; + }eg; + + my $comments_word = template_var('terms')->{comments}; + + my $comments_re = qr/(?:comments|\Q$comments_word\E)$s*\#?$s* + \d+(?:$s*,$s*\#?$s*\d+)+/ix; + + $text =~ s{($comments_re)}{ + my $match = $1; + $match =~ s|((?:#$s*)?(\d+))|<a href="$current_bugurl#c$2">$1</a>|g; + $match; + }eg; # Old duplicate markers. These don't use $bug_word because they are old # and were never customizable. @@ -264,10 +299,9 @@ sub quoteUrls { # Creates a link to an attachment, including its title. sub get_attachment_link { my ($attachid, $link_text, $user) = @_; - 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 = ""; @@ -315,12 +349,11 @@ sub get_bug_link { my ($bug, $link_text, $options) = @_; $options ||= {}; $options->{user} ||= Bugzilla->user; - my $dbh = Bugzilla->dbh; - if (defined $bug) { + if (defined $bug && $bug ne '') { if (!blessed($bug)) { require Bugzilla::Bug; - $bug = new Bugzilla::Bug($bug); + $bug = new Bugzilla::Bug({ id => $bug, cache => 1 }); } return $link_text if $bug->{error}; } @@ -386,18 +419,18 @@ sub mtime_filter { # Set up the skin CSS cascade: # -# 1. YUI CSS -# 2. Standard Bugzilla stylesheet set (persistent) -# 3. Third-party "skin" stylesheet set, per user prefs (persistent) -# 4. Page-specific styles -# 5. Custom Bugzilla stylesheet set (persistent) +# 1. standard/global.css +# 2. YUI CSS +# 3. Standard Bugzilla stylesheet set +# 4. Third-party "skin" stylesheet set, per user prefs +# 5. Inline css passed to global/header.html.tmpl +# 6. Custom Bugzilla stylesheet set sub css_files { my ($style_urls, $yui, $yui_css) = @_; - - # global.css goes on every page, and so does IE-fixes.css. - my @requested_css = ('skins/standard/global.css', @$style_urls, - 'skins/standard/IE-fixes.css'); + + # global.css goes on every page. + my @requested_css = ('skins/standard/global.css', @$style_urls); my @yui_required_css; foreach my $yui_name (@$yui) { @@ -414,7 +447,12 @@ sub css_files { push(@{ $by_type{$key} }, $set->{$key}); } } - + + # build unified + $by_type{unified_standard_skin} = _concatenate_css($by_type{standard}, + $by_type{skin}); + $by_type{unified_custom} = _concatenate_css($by_type{custom}); + return \%by_type; } @@ -422,30 +460,137 @@ sub _css_link_set { my ($file_name) = @_; my %set = (standard => mtime_filter($file_name)); - - # We use (^|/) to allow Extensions to use the skins system if they - # want. - if ($file_name !~ m{(^|/)skins/standard/}) { + + # We use (?:^|/) to allow Extensions to use the skins system if they want. + if ($file_name !~ m{(?:^|/)skins/standard/}) { return \%set; } my $skin = Bugzilla->user->settings->{skin}->{value}; my $cgi_path = bz_locations()->{'cgi_path'}; my $skin_file_name = $file_name; - $skin_file_name =~ s{(^|/)skins/standard/}{skins/contrib/$skin/}; + $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); } my $custom_file_name = $file_name; - $custom_file_name =~ s{(^|/)skins/standard/}{skins/custom/}; + $custom_file_name =~ s{(?:^|/)skins/standard/}{skins/custom/}; if (my $custom_mtime = _mtime("$cgi_path/$custom_file_name")) { $set{custom} = mtime_filter($custom_file_name, $custom_mtime); } - + return \%set; } +sub _concatenate_css { + my @sources = map { @$_ } @_; + return unless @sources; + + my %files = + map { + (my $file = $_) =~ s/(^[^\?]+)\?.+/$1/; + $_ => $file; + } @sources; + + my $cgi_path = bz_locations()->{cgi_path}; + my $skins_path = bz_locations()->{assetsdir}; + + # build minified files + my @minified; + foreach my $source (@sources) { + next unless -e "$cgi_path/$files{$source}"; + my $file = $skins_path . '/' . md5_hex($source) . '.css'; + if (!-e $file) { + my $content = read_file("$cgi_path/$files{$source}"); + + # minify + $content =~ s{/\*.*?\*/}{}sg; # comments + $content =~ s{(^\s+|\s+$)}{}mg; # leading/trailing whitespace + $content =~ s{\n}{}g; # single line + + # rewrite urls + $content =~ s{url\(([^\)]+)\)}{_css_url_rewrite($source, $1)}eig; + + write_file($file, "/* $files{$source} */\n" . $content . "\n"); + } + push @minified, $file; + } + + # concat files + my $file = $skins_path . '/' . md5_hex(join(' ', @sources)) . '.css'; + if (!-e $file) { + my $content = ''; + foreach my $source (@minified) { + $content .= read_file($source); + } + write_file($file, $content); + } + + $file =~ s/^\Q$cgi_path\E\///o; + return mtime_filter($file); +} + +sub _css_url_rewrite { + my ($source, $url) = @_; + # rewrite relative urls as the unified stylesheet lives in a different + # directory from the source + $url =~ s/(^['"]|['"]$)//g; + if (substr($url, 0, 1) eq '/' || substr($url, 0, 5) eq 'data:') { + return 'url(' . $url . ')'; + } + return 'url(../../' . ($ENV{'PROJECT'} ? '../' : '') . dirname($source) . '/' . $url . ')'; +} + +sub _concatenate_js { + return @_ unless CONCATENATE_ASSETS; + my ($sources) = @_; + return [] unless $sources; + $sources = ref($sources) ? $sources : [ $sources ]; + + my %files = + map { + (my $file = $_) =~ s/(^[^\?]+)\?.+/$1/; + $_ => $file; + } @$sources; + + my $cgi_path = bz_locations()->{cgi_path}; + my $skins_path = bz_locations()->{assetsdir}; + + # build minified files + my @minified; + foreach my $source (@$sources) { + next unless -e "$cgi_path/$files{$source}"; + my $file = $skins_path . '/' . md5_hex($source) . '.js'; + if (!-e $file) { + my $content = read_file("$cgi_path/$files{$source}"); + + # minimal minification + $content =~ s#/\*.*?\*/##sg; # block comments + $content =~ s#(^ +| +$)##gm; # leading/trailing spaces + $content =~ s#^//.+$##gm; # single line comments + $content =~ s#\n{2,}#\n#g; # blank lines + $content =~ s#(^\s+|\s+$)##g; # whitespace at the start/end of file + + write_file($file, ";/* $files{$source} */\n" . $content . "\n"); + } + push @minified, $file; + } + + # concat files + my $file = $skins_path . '/' . md5_hex(join(' ', @$sources)) . '.js'; + if (!-e $file) { + my $content = ''; + foreach my $source (@minified) { + $content .= read_file($source); + } + write_file($file, $content); + } + + $file =~ s/^\Q$cgi_path\E\///o; + return [ $file ]; +} + # YUI dependency resolution sub yui_resolve_deps { my ($yui, $yui_deps) = @_; @@ -510,6 +655,21 @@ $Template::Stash::LIST_OPS->{ clone } = return [@$list]; }; +# Allow us to sort the list of fields correctly +$Template::Stash::LIST_OPS->{ sort_by_field_name } = + sub { + sub field_name { + if ($_[0] eq 'noop') { + # Sort --- first + return ''; + } + # Otherwise sort by field_desc or description + return $_[1]{$_[0]} || $_[0]; + } + my ($list, $field_desc) = @_; + return [ sort { lc field_name($a, $field_desc) cmp lc field_name($b, $field_desc) } @$list ]; + }; + # Allow us to still get the scalar if we use the list operation ".0" on it, # as we often do for defaults in query.cgi and other places. $Template::Stash::SCALAR_OPS->{ 0 } = @@ -522,10 +682,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; @@ -631,6 +790,8 @@ sub create { $var =~ s/([\\\'\"\/])/\\$1/g; $var =~ s/\n/\\n/g; $var =~ s/\r/\\r/g; + $var =~ s/\x{2028}/\\u2028/g; # unicode line separator + $var =~ s/\x{2029}/\\u2029/g; # unicode paragraph separator $var =~ s/\@/\\x40/g; # anti-spam for email addresses $var =~ s/</\\x3c/g; $var =~ s/>/\\x3e/g; @@ -646,9 +807,10 @@ sub create { # Strips out control characters excepting whitespace strip_control_chars => sub { my ($data) = @_; + state $use_utf8 = Bugzilla->params->{'utf8'}; # Only run for utf8 to avoid issues with other multibyte encodings # that may be reassigning meaning to ascii characters. - if (Bugzilla->params->{'utf8'}) { + if ($use_utf8) { $data =~ s/(?![\t\r\n])[[:cntrl:]]//g; } return $data; @@ -668,14 +830,6 @@ sub create { return $var; }, - # Prevents line break on hyphens and whitespaces. - no_break => sub { - my ($var) = @_; - $var =~ s/ /\ /g; - $var =~ s/-/\‑/g; - return $var; - }, - xml => \&Bugzilla::Util::xml_quote , # This filter is similar to url_quote but used a \ instead of a % @@ -812,9 +966,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; @@ -872,14 +1024,42 @@ sub create { # started the session. 'sudoer' => sub { return Bugzilla->sudoer; }, - # Allow templates to access the "corect" URLBase value + # Allow templates to access the "correct" URLBase value 'urlbase' => sub { return Bugzilla::Util::correct_urlbase(); }, # Allow templates to access docs url with users' preferred language - 'docs_urlbase' => sub { - my $language = Bugzilla->current_language; - my $docs_urlbase = Bugzilla->params->{'docs_urlbase'}; - $docs_urlbase =~ s/\%lang\%/$language/; + # We fall back to English if documentation in the preferred + # language is not available + 'docs_urlbase' => sub { + my $docs_urlbase; + my $lang = Bugzilla->current_language; + # Translations currently available on readthedocs.org + my @rtd_translations = ('en', 'fr'); + + if ($lang ne 'en' && -f "docs/$lang/html/index.html") { + $docs_urlbase = "docs/$lang/html/"; + } + elsif (-f "docs/en/html/index.html") { + $docs_urlbase = "docs/en/html/"; + } + else { + if (!grep { $_ eq $lang } @rtd_translations) { + $lang = "en"; + } + + my $version = BUGZILLA_VERSION; + $version =~ /^(\d+)\.(\d+)/; + if ($2 % 2 == 1) { + # second number is odd; development version + $version = 'latest'; + } + else { + $version = "$1.$2"; + } + + $docs_urlbase = "https://bugzilla.readthedocs.org/$lang/$version/"; + } + return $docs_urlbase; }, @@ -904,6 +1084,12 @@ sub create { return $cookie ? issue_hash_token(['login_request', $cookie]) : ''; }, + 'get_api_token' => sub { + return '' unless Bugzilla->user->id; + my $cache = Bugzilla->request_cache; + return $cache->{api_token} //= issue_api_token(); + }, + # A way for all templates to get at Field data, cached. 'bug_fields' => sub { my $cache = Bugzilla->request_cache; @@ -922,6 +1108,12 @@ sub create { 'css_files' => \&css_files, yui_resolve_deps => \&yui_resolve_deps, + concatenate_js => \&_concatenate_js, + + # All classifications (sorted by sortkey, name) + 'all_classifications' => sub { + return [map { $_->name } Bugzilla::Classification->get_all()]; + }, # Whether or not keywords are enabled, in this Bugzilla. 'use_keywords' => sub { return Bugzilla::Keyword->any_exist; }, @@ -1160,3 +1352,29 @@ Returns: nothing =head1 SEE ALSO L<Bugzilla>, L<Template> + +=head1 B<Methods in need of POD> + +=over + +=item multiline_sprintf + +=item create + +=item css_files + +=item mtime_filter + +=item yui_resolve_deps + +=item process + +=item get_bug_link + +=item quoteUrls + +=item get_attachment_link + +=item SAFE_URL_REGEXP + +=back diff --git a/Bugzilla/Template/Context.pm b/Bugzilla/Template/Context.pm index 9dc1f08f9..470e6a9ee 100644 --- a/Bugzilla/Template/Context.pm +++ b/Bugzilla/Template/Context.pm @@ -7,8 +7,12 @@ # This exists to implement the template-before_process hook. package Bugzilla::Template::Context; + +use 5.10.1; use strict; -use base qw(Template::Context); +use warnings; + +use parent qw(Template::Context); use Bugzilla::Hook; use Scalar::Util qw(blessed); @@ -81,6 +85,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; @@ -88,3 +100,15 @@ sub DESTROY { }; 1; + +=head1 B<Methods in need of POD> + +=over + +=item stash + +=item filter + +=item process + +=back diff --git a/Bugzilla/Template/Plugin/Bugzilla.pm b/Bugzilla/Template/Plugin/Bugzilla.pm index e2b59c7a8..806dd903b 100644 --- a/Bugzilla/Template/Plugin/Bugzilla.pm +++ b/Bugzilla/Template/Plugin/Bugzilla.pm @@ -7,9 +7,11 @@ package Bugzilla::Template::Plugin::Bugzilla; +use 5.10.1; use strict; +use warnings; -use base qw(Template::Plugin); +use parent qw(Template::Plugin); use Bugzilla; diff --git a/Bugzilla/Template/Plugin/Hook.pm b/Bugzilla/Template/Plugin/Hook.pm index f1de7a602..669c77614 100644 --- a/Bugzilla/Template/Plugin/Hook.pm +++ b/Bugzilla/Template/Plugin/Hook.pm @@ -6,8 +6,12 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Template::Plugin::Hook; + +use 5.10.1; use strict; -use base qw(Template::Plugin); +use warnings; + +use parent qw(Template::Plugin); use Bugzilla::Constants; use Bugzilla::Install::Util qw(template_include_path); diff --git a/Bugzilla/Token.pm b/Bugzilla/Token.pm index c7e9f645f..84d86b8c6 100644 --- a/Bugzilla/Token.pm +++ b/Bugzilla/Token.pm @@ -5,15 +5,11 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -################################################################################ -# Module Initialization -################################################################################ +package Bugzilla::Token; -# Make it harder for us to do dangerous things in Perl. +use 5.10.1; use strict; - -# Bundle the functions in this file together into the "Bugzilla::Token" package. -package Bugzilla::Token; +use warnings; use Bugzilla::Constants; use Bugzilla::Error; @@ -26,15 +22,30 @@ use Date::Parse; use File::Basename; use Digest::SHA qw(hmac_sha256_base64); -use base qw(Exporter); +use parent qw(Exporter); -@Bugzilla::Token::EXPORT = qw(issue_session_token check_token_data delete_token +@Bugzilla::Token::EXPORT = qw(issue_api_token issue_session_token + check_token_data delete_token issue_hash_token check_hash_token); ################################################################################ # Public Functions ################################################################################ +# Create a token used for internal API authentication +sub issue_api_token { + # Generates a random token, adds it to the tokens table if one does not + # already exist, and returns the token to the caller. + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my ($token) = $dbh->selectrow_array(" + SELECT token FROM tokens + WHERE userid = ? AND tokentype = 'api_token' + AND (" . $dbh->sql_date_math('issuedate', '+', (MAX_TOKEN_AGE * 24 - 12), 'HOUR') . ") > NOW()", + undef, $user->id); + return $token // _create_token($user->id, 'api_token', ''); +} + # Creates and sends a token to create a new user account. # It assumes that the login has the correct format and is not already in use. sub issue_new_user_account_token { @@ -44,7 +55,7 @@ sub issue_new_user_account_token { my $vars = {}; # Is there already a pending request for this login name? If yes, do not throw - # an error because the user may have lost his email with the token inside. + # an error because the user may have lost their email with the token inside. # But to prevent using this way to mailbomb an email address, make sure # the last request is old enough before sending a new email (default: 10 minutes). @@ -71,7 +82,7 @@ sub issue_new_user_account_token { # In 99% of cases, the user getting the confirmation email is the same one # who made the request, and so it is reasonable to send the email in the same - # language used to view the "Create a New Account" page (we cannot use his + # language used to view the "Create a New Account" page (we cannot use their # user prefs as the user has no account yet!). MessageToMTA($message); } @@ -127,16 +138,18 @@ sub IssuePasswordToken { ThrowUserError('too_soon_for_new_token', {'type' => 'password'}) if $too_soon; - my ($token, $token_ts) = _create_token($user->id, 'password', remote_ip()); + my $ip_addr = remote_ip(); + my ($token, $token_ts) = _create_token($user->id, 'password', $ip_addr); # Mail the user the token along with instructions for using it. my $template = Bugzilla->template_inner($user->setting('lang')); my $vars = {}; $vars->{'token'} = $token; + $vars->{'ip_addr'} = $ip_addr; $vars->{'emailaddress'} = $user->email; $vars->{'expiration_ts'} = ctime($token_ts + MAX_TOKEN_AGE * 86400); - # The user is not logged in (else he wouldn't request a new password). + # The user is not logged in (else they wouldn't request a new password). # So we have to pass this information to the template. $vars->{'timezone'} = $user->timezone; @@ -377,7 +390,7 @@ sub check_token_data { { # Something is going wrong. Ask confirmation before processing. # It is possible that someone tried to trick an administrator. - # In this case, we want to know his name! + # In this case, we want to know their name! require Bugzilla::User; my $vars = {}; @@ -419,6 +432,9 @@ sub _create_token { trick_taint($tokentype); trick_taint($eventdata); + my $is_shadow = Bugzilla->is_shadow_db; + $dbh = Bugzilla->switch_to_main_db() if $is_shadow; + $dbh->bz_start_transaction(); my $token = GenerateUniqueToken(); @@ -431,8 +447,10 @@ sub _create_token { if (wantarray) { my (undef, $token_ts, undef) = GetTokenData($token); $token_ts = str2time($token_ts); + Bugzilla->switch_to_shadow_db() if $is_shadow; return ($token, $token_ts); } else { + Bugzilla->switch_to_shadow_db() if $is_shadow; return $token; } } @@ -469,12 +487,20 @@ Bugzilla::Token - Provides different routines to manage tokens. =over +=item C<issue_api_token($login_name)> + + Description: Creates a token that can be used for API calls on the web page. + + Params: None. + + Returns: The token. + =item C<issue_new_user_account_token($login_name)> Description: Creates and sends a token per email to the email address requesting a new user account. It doesn't check whether the user account already exists. The user will have to - use this token to confirm the creation of his user account. + use this token to confirm the creation of their user account. Params: $login_name - The new login name requested by the user. @@ -497,7 +523,7 @@ Bugzilla::Token - Provides different routines to manage tokens. Description: Sends a token per email to the given user. This token can be used to change the password (e.g. in case the user - cannot remember his password and wishes to enter a new one). + cannot remember their password and wishes to enter a new one). Params: $user - User object of the user requesting a new password. @@ -527,7 +553,7 @@ Bugzilla::Token - Provides different routines to manage tokens. Description: Invalidates an existing token, generally when the token is used for an action which is not the one expected. An email is sent - to the user who originally requested this token to inform him + to the user who originally requested this token to inform them that this token has been invalidated (e.g. because an hacker tried to use this token for some malicious action). @@ -540,7 +566,7 @@ Bugzilla::Token - Provides different routines to manage tokens. =item C<DeletePasswordTokens($user_id, $reason)> Description: Cancels all password tokens for the given user. Emails are sent - to the user to inform him about this action. + to the user to inform them about this action. Params: $user_id: The user ID of the user account whose password tokens are canceled. @@ -618,3 +644,13 @@ although they can be used separately. =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item check_hash_token + +=item issue_hash_token + +=back diff --git a/Bugzilla/Update.pm b/Bugzilla/Update.pm index 29133ecce..72a7108a8 100644 --- a/Bugzilla/Update.pm +++ b/Bugzilla/Update.pm @@ -7,7 +7,9 @@ package Bugzilla::Update; +use 5.10.1; use strict; +use warnings; use Bugzilla::Constants; diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm index 5d1c42a02..e63be93dd 100644 --- a/Bugzilla/User.pm +++ b/Bugzilla/User.pm @@ -5,15 +5,11 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -################################################################################ -# Module Initialization -################################################################################ +package Bugzilla::User; -# Make it harder for us to do dangerous things in Perl. +use 5.10.1; use strict; - -# This module implements utilities for dealing with Bugzilla users. -package Bugzilla::User; +use warnings; use Bugzilla::Error; use Bugzilla::Util; @@ -24,16 +20,20 @@ use Bugzilla::Product; use Bugzilla::Classification; use Bugzilla::Field; use Bugzilla::Group; +use Bugzilla::BugUserLastVisit; +use Bugzilla::Hook; use DateTime::TimeZone; use List::Util qw(max); +use List::MoreUtils qw(any); use Scalar::Util qw(blessed); +use Storable qw(dclone); use URI; use URI::QueryParam; -use base qw(Bugzilla::Object Exporter); +use parent qw(Bugzilla::Object Exporter); @Bugzilla::User::EXPORT = qw(is_available_username - login_to_id user_id_to_login validate_password + login_to_id validate_password validate_password_check USER_MATCH_MULTIPLE USER_MATCH_FAILED USER_MATCH_SUCCESS MATCH_SKIP_CONFIRM ); @@ -147,12 +147,80 @@ sub super_user { return $user; } +sub _update_groups { + my $self = shift; + my $group_changes = shift; + my $changes = shift; + my $dbh = Bugzilla->dbh; + + # Update group settings. + my $sth_add_mapping = $dbh->prepare( + qq{INSERT INTO user_group_map ( + user_id, group_id, isbless, grant_type + ) VALUES ( + ?, ?, ?, ? + ) + }); + my $sth_remove_mapping = $dbh->prepare( + qq{DELETE FROM user_group_map + WHERE user_id = ? + AND group_id = ? + AND isbless = ? + AND grant_type = ? + }); + + foreach my $is_bless (keys %$group_changes) { + my ($removed, $added) = @{$group_changes->{$is_bless}}; + + foreach my $group (@$removed) { + $sth_remove_mapping->execute( + $self->id, $group->id, $is_bless, GRANT_DIRECT + ); + } + foreach my $group (@$added) { + $sth_add_mapping->execute( + $self->id, $group->id, $is_bless, GRANT_DIRECT + ); + } + + if (! $is_bless) { + my $query = qq{ + INSERT INTO profiles_activity + (userid, who, profiles_when, fieldid, oldvalue, newvalue) + VALUES ( ?, ?, now(), ?, ?, ?) + }; + + $dbh->do( + $query, undef, + $self->id, Bugzilla->user->id, + get_field_id('bug_group'), + join(', ', map { $_->name } @$removed), + join(', ', map { $_->name } @$added) + ); + } + else { + # XXX: should create profiles_activity entries for blesser changes. + } + + Bugzilla->memcached->clear_config({ key => 'user_groups.' . $self->id }); + + my $type = $is_bless ? 'bless_groups' : 'groups'; + $changes->{$type} = [ + [ map { $_->name } @$removed ], + [ map { $_->name } @$added ], + ]; + } +} + sub update { my $self = shift; my $options = shift; - + + my $group_changes = delete $self->{_group_changes}; + my $changes = $self->SUPER::update(@_); my $dbh = Bugzilla->dbh; + $self->_update_groups($group_changes, $changes); if (exists $changes->{login_name}) { # Delete all the tokens related to the userid @@ -246,8 +314,9 @@ sub _check_is_enabled { # Mutators ################################################################################ -sub set_disable_mail { $_[0]->set('disable_mail', $_[1]); } -sub set_extern_id { $_[0]->set('extern_id', $_[1]); } +sub set_disable_mail { $_[0]->set('disable_mail', $_[1]); } +sub set_email_enabled { $_[0]->set('disable_mail', !$_[1]); } +sub set_extern_id { $_[0]->set('extern_id', $_[1]); } sub set_login { my ($self, $login) = @_; @@ -269,6 +338,112 @@ sub set_disabledtext { $_[0]->set('is_enabled', $_[1] ? 0 : 1); } +sub set_groups { + my $self = shift; + $self->_set_groups(GROUP_MEMBERSHIP, @_); +} + +sub set_bless_groups { + my $self = shift; + + # The person making the change needs to be in the editusers group + Bugzilla->user->in_group('editusers') + || ThrowUserError("auth_failure", {group => "editusers", + reason => "cant_bless", + action => "edit", + object => "users"}); + + $self->_set_groups(GROUP_BLESS, @_); +} + +sub _set_groups { + my $self = shift; + my $is_bless = shift; + my $changes = shift; + my $dbh = Bugzilla->dbh; + + # The person making the change is $user, $self is the person being changed + my $user = Bugzilla->user; + + # Input is a hash of arrays. Key is 'set', 'add' or 'remove'. The array + # is a list of group ids and/or names. + + # First turn the arrays into group objects. + $changes = $self->_set_groups_to_object($changes); + + # Get a list of the groups the user currently is a member of + my $ids = $dbh->selectcol_arrayref( + q{SELECT DISTINCT group_id + FROM user_group_map + WHERE user_id = ? AND isbless = ? AND grant_type = ?}, + undef, $self->id, $is_bless, GRANT_DIRECT); + + my $current_groups = Bugzilla::Group->new_from_list($ids); + my $new_groups = dclone($current_groups); + + # Record the changes + if (exists $changes->{set}) { + $new_groups = $changes->{set}; + + # We need to check the user has bless rights on the existing groups + # If they don't, then we need to add them back to new_groups + foreach my $group (@$current_groups) { + if (! $user->can_bless($group->id)) { + push @$new_groups, $group + unless grep { $_->id eq $group->id } @$new_groups; + } + } + } + else { + foreach my $group (@{$changes->{remove} // []}) { + @$new_groups = grep { $_->id ne $group->id } @$new_groups; + } + foreach my $group (@{$changes->{add} // []}) { + push @$new_groups, $group + unless grep { $_->id eq $group->id } @$new_groups; + } + } + + # Stash the changes, so self->update can actually make them + my @diffs = diff_arrays($current_groups, $new_groups, 'id'); + if (scalar(@{$diffs[0]}) || scalar(@{$diffs[1]})) { + $self->{_group_changes}{$is_bless} = \@diffs; + } +} + +sub _set_groups_to_object { + my $self = shift; + my $changes = shift; + my $user = Bugzilla->user; + + foreach my $key (keys %$changes) { + # Check we were given an array + unless (ref($changes->{$key}) eq 'ARRAY') { + ThrowCodeError( + 'param_invalid', + { param => $changes->{$key}, function => $key } + ); + } + + # Go through the array, and turn items into group objects + my @groups = (); + foreach my $value (@{$changes->{$key}}) { + my $type = $value =~ /^\d+$/ ? 'id' : 'name'; + my $group = Bugzilla::Group->new({$type => $value}); + + if (! $group || ! $user->can_bless($group->id)) { + ThrowUserError('auth_failure', + { group => $value, reason => 'cant_bless', + action => 'edit', object => 'users' }); + } + push @groups, $group; + } + $changes->{$key} = \@groups; + } + + return $changes; +} + sub update_last_seen_date { my $self = shift; return unless $self->id; @@ -283,6 +458,7 @@ sub update_last_seen_date { # pending changes $dbh->do("UPDATE profiles SET last_seen_date = ? WHERE userid = ?", undef, $date, $self->id); + Bugzilla->memcached->clear({ table => 'profiles', id => $self->id }); } } @@ -325,7 +501,7 @@ sub authorizer { } # Generate a string to identify the user by name + login if the user -# has a name or by login only if she doesn't. +# has a name or by login only if they don't. sub identity { my $self = shift; @@ -433,6 +609,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 # ########################## @@ -648,56 +849,100 @@ sub groups { return $self->{groups} if defined $self->{groups}; return [] unless $self->id; - my $dbh = Bugzilla->dbh; - my $groups_to_check = $dbh->selectcol_arrayref( - q{SELECT DISTINCT group_id - FROM user_group_map - WHERE user_id = ? AND isbless = 0}, undef, $self->id); + my $user_groups_key = "user_groups." . $self->id; + my $groups = Bugzilla->memcached->get_config({ + key => $user_groups_key + }); - my $rows = $dbh->selectall_arrayref( - "SELECT DISTINCT grantor_id, member_id - FROM group_group_map - WHERE grant_type = " . GROUP_MEMBERSHIP); + if (!$groups) { + my $dbh = Bugzilla->dbh; + my $groups_to_check = $dbh->selectcol_arrayref( + "SELECT DISTINCT group_id + FROM user_group_map + WHERE user_id = ? AND isbless = 0", undef, $self->id); + + my $grant_type_key = 'group_grant_type_' . GROUP_MEMBERSHIP; + my $membership_rows = Bugzilla->memcached->get_config({ + key => $grant_type_key, + }); + if (!$membership_rows) { + $membership_rows = $dbh->selectall_arrayref( + "SELECT DISTINCT grantor_id, member_id + FROM group_group_map + WHERE grant_type = " . GROUP_MEMBERSHIP); + Bugzilla->memcached->set_config({ + key => $grant_type_key, + data => $membership_rows, + }); + } - my %group_membership; - foreach my $row (@$rows) { - my ($grantor_id, $member_id) = @$row; - push (@{ $group_membership{$member_id} }, $grantor_id); - } - - # Let's walk the groups hierarchy tree (using FIFO) - # On the first iteration it's pre-filled with direct groups - # membership. Later on, each group can add its own members into the - # FIFO. Circular dependencies are eliminated by checking - # $checked_groups{$member_id} hash values. - # As a result, %groups will have all the groups we are the member of. - my %checked_groups; - my %groups; - while (scalar(@$groups_to_check) > 0) { - # Pop the head group from FIFO - my $member_id = shift @$groups_to_check; - - # Skip the group if we have already checked it - if (!$checked_groups{$member_id}) { - # Mark group as checked - $checked_groups{$member_id} = 1; - - # Add all its members to the FIFO check list - # %group_membership contains arrays of group members - # for all groups. Accessible by group number. - my $members = $group_membership{$member_id}; - my @new_to_check = grep(!$checked_groups{$_}, @$members); - push(@$groups_to_check, @new_to_check); - - $groups{$member_id} = 1; + my %group_membership; + foreach my $row (@$membership_rows) { + my ($grantor_id, $member_id) = @$row; + push (@{ $group_membership{$member_id} }, $grantor_id); } - } - $self->{groups} = Bugzilla::Group->new_from_list([keys %groups]); + # Let's walk the groups hierarchy tree (using FIFO) + # On the first iteration it's pre-filled with direct groups + # membership. Later on, each group can add its own members into the + # FIFO. Circular dependencies are eliminated by checking + # $checked_groups{$member_id} hash values. + # As a result, %groups will have all the groups we are the member of. + my %checked_groups; + my %groups; + while (scalar(@$groups_to_check) > 0) { + # Pop the head group from FIFO + my $member_id = shift @$groups_to_check; + + # Skip the group if we have already checked it + if (!$checked_groups{$member_id}) { + # Mark group as checked + $checked_groups{$member_id} = 1; + + # Add all its members to the FIFO check list + # %group_membership contains arrays of group members + # for all groups. Accessible by group number. + my $members = $group_membership{$member_id}; + my @new_to_check = grep(!$checked_groups{$_}, @$members); + push(@$groups_to_check, @new_to_check); + + $groups{$member_id} = 1; + } + } + $groups = [ keys %groups ]; + + Bugzilla->memcached->set_config({ + key => $user_groups_key, + data => $groups, + }); + } + $self->{groups} = Bugzilla::Group->new_from_list($groups); return $self->{groups}; } +sub last_visited { + my ($self) = @_; + + return Bugzilla::BugUserLastVisit->match({ user_id => $self->id }); +} + +sub is_involved_in_bug { + my ($self, $bug) = @_; + my $user_id = $self->id; + my $user_login = $self->login; + + return unless $user_id; + return 1 if $user_id == $bug->assigned_to->id; + return 1 if $user_id == $bug->reporter->id; + + if (Bugzilla->params->{'useqacontact'} and $bug->qa_contact) { + return 1 if $user_id == $bug->qa_contact->id; + } + + return any { $user_login eq $_ } @{ $bug->cc }; +} + # It turns out that calling ->id on objects a few hundred thousand # times is pretty slow. (It showed up as a significant time contributor # when profiling xt/search.t.) So we cache the group ids separately from @@ -734,34 +979,42 @@ sub bless_groups { return $self->{'bless_groups'}; } + if (Bugzilla->params->{usevisibilitygroups} + && !@{ $self->visible_groups_inherited }) { + return []; + } + my $dbh = Bugzilla->dbh; - # Get all groups for the user where: - # + They have direct bless privileges - # + They are a member of a group that inherits bless privs. - my @group_ids = map {$_->id} @{ $self->groups }; - @group_ids = (-1) if !@group_ids; - my $query = - 'SELECT DISTINCT groups.id - FROM groups, user_group_map, group_group_map AS ggm - WHERE user_group_map.user_id = ? - AND ( (user_group_map.isbless = 1 - AND groups.id=user_group_map.group_id) - OR (groups.id = ggm.grantor_id - AND ggm.grant_type = ' . GROUP_BLESS . ' - AND ' . $dbh->sql_in('ggm.member_id', \@group_ids) - . ') )'; - - # If visibilitygroups are used, restrict the set of groups. - if (Bugzilla->params->{'usevisibilitygroups'}) { - return [] if !$self->visible_groups_as_string; - # Users need to see a group in order to bless it. + # Get all groups for the user where they have direct bless privileges. + my $query = " + SELECT DISTINCT group_id + FROM user_group_map + WHERE user_id = ? + AND isbless = 1"; + if (Bugzilla->params->{usevisibilitygroups}) { $query .= " AND " - . $dbh->sql_in('groups.id', $self->visible_groups_inherited); + . $dbh->sql_in('group_id', $self->visible_groups_inherited); + } + + # Get all groups for the user where they are a member of a group that + # inherits bless privs. + my @group_ids = map { $_->id } @{ $self->groups }; + if (@group_ids) { + $query .= " + UNION + SELECT DISTINCT grantor_id + FROM group_group_map + WHERE grant_type = " . GROUP_BLESS . " + AND " . $dbh->sql_in('member_id', \@group_ids); + if (Bugzilla->params->{usevisibilitygroups}) { + $query .= " AND " + . $dbh->sql_in('grantor_id', $self->visible_groups_inherited); + } } my $ids = $dbh->selectcol_arrayref($query, undef, $self->id); - return $self->{'bless_groups'} = Bugzilla::Group->new_from_list($ids); + return $self->{bless_groups} = Bugzilla::Group->new_from_list($ids); } sub in_group { @@ -804,8 +1057,7 @@ sub in_group_id { sub groups_with_icon { my $self = shift; - my @groups = grep { $_->icon_url } @{ $self->groups }; - return \@groups; + return $self->{groups_with_icon} //= [grep { $_->icon_url } @{ $self->groups }]; } sub get_products_by_permission { @@ -856,15 +1108,34 @@ sub can_edit_product { my ($self, $prod_id) = @_; my $dbh = Bugzilla->dbh; - my $has_external_groups = - $dbh->selectrow_array('SELECT 1 - FROM group_control_map - WHERE product_id = ? - AND canedit != 0 - AND group_id NOT IN(' . $self->groups_as_string . ')', - undef, $prod_id); + if (Bugzilla->params->{'or_groups'}) { + my $groups = $self->groups_as_string; + # For or-groups, we check if there are any can_edit groups for the + # product, and if the user is in any of them. If there are none or + # the user is in at least one of them, they can edit the product + my ($cnt_can_edit, $cnt_group_member) = $dbh->selectrow_array( + "SELECT SUM(p.cnt_can_edit), + SUM(p.cnt_group_member) + FROM (SELECT CASE WHEN canedit = 1 THEN 1 ELSE 0 END AS cnt_can_edit, + CASE WHEN canedit = 1 AND group_id IN ($groups) THEN 1 ELSE 0 END AS cnt_group_member + FROM group_control_map + WHERE product_id = $prod_id) AS p"); + return (!$cnt_can_edit or $cnt_group_member); + } + else { + # For and-groups, a user needs to be in all canedit groups. Therefore + # if the user is not in a can_edit group for the product, they cannot + # edit the product. + my $has_external_groups = + $dbh->selectrow_array('SELECT 1 + FROM group_control_map + WHERE product_id = ? + AND canedit != 0 + AND group_id NOT IN(' . $self->groups_as_string . ')', + undef, $prod_id); - return !$has_external_groups; + return !$has_external_groups; + } } sub can_see_bug { @@ -885,9 +1156,6 @@ sub visible_bugs { my @check_ids = grep(!exists $visible_cache->{$_}, @bug_ids); if (@check_ids) { - my $dbh = Bugzilla->dbh; - my $user_id = $self->id; - foreach my $id (@check_ids) { my $orig_id = $id; detaint_natural($id) @@ -895,56 +1163,113 @@ sub visible_bugs { function => 'Bugzilla::User->visible_bugs'}); } - my $sth; - # Speed up the can_see_bug case. - if (scalar(@check_ids) == 1) { - $sth = $self->{_sth_one_visible_bug}; - } - $sth ||= $dbh->prepare( - # This checks for groups that the bug is in that the user - # *isn't* in. Then, in the Perl code below, we check if - # the user can otherwise access the bug (for example, by being - # the assignee or QA Contact). - # - # The DISTINCT exists because the bug could be in *several* - # groups that the user isn't in, but they will all return the - # same result for bug_group_map.bug_id (so DISTINCT filters - # out duplicate rows). - "SELECT DISTINCT bugs.bug_id, reporter, assigned_to, qa_contact, - reporter_accessible, cclist_accessible, cc.who, - bug_group_map.bug_id - FROM bugs - LEFT JOIN cc - ON cc.bug_id = bugs.bug_id - AND cc.who = $user_id - LEFT JOIN bug_group_map - ON bugs.bug_id = bug_group_map.bug_id - AND bug_group_map.group_id NOT IN (" - . $self->groups_as_string . ') - WHERE bugs.bug_id IN (' . join(',', ('?') x @check_ids) . ') - AND creation_ts IS NOT NULL '); - if (scalar(@check_ids) == 1) { - $self->{_sth_one_visible_bug} = $sth; - } - - $sth->execute(@check_ids); - my $use_qa_contact = Bugzilla->params->{'useqacontact'}; - while (my $row = $sth->fetchrow_arrayref) { - my ($bug_id, $reporter, $owner, $qacontact, $reporter_access, - $cclist_access, $isoncclist, $missinggroup) = @$row; - $visible_cache->{$bug_id} ||= - ((($reporter == $user_id) && $reporter_access) - || ($use_qa_contact - && $qacontact && ($qacontact == $user_id)) - || ($owner == $user_id) - || ($isoncclist && $cclist_access) - || !$missinggroup) ? 1 : 0; - } + Bugzilla->params->{'or_groups'} + ? $self->_visible_bugs_check_or(\@check_ids) + : $self->_visible_bugs_check_and(\@check_ids); } return [grep { $visible_cache->{blessed $_ ? $_->id : $_} } @$bugs]; } +sub _visible_bugs_check_or { + my ($self, $check_ids) = @_; + my $visible_cache = $self->{_visible_bugs_cache}; + my $dbh = Bugzilla->dbh; + my $user_id = $self->id; + + my $sth; + # Speed up the can_see_bug case. + if (scalar(@$check_ids) == 1) { + $sth = $self->{_sth_one_visible_bug}; + } + my $query = qq{ + SELECT DISTINCT bugs.bug_id + FROM bugs + LEFT JOIN bug_group_map AS security_map ON bugs.bug_id = security_map.bug_id + LEFT JOIN cc AS security_cc ON bugs.bug_id = security_cc.bug_id AND security_cc.who = $user_id + WHERE bugs.bug_id IN (} . join(',', ('?') x @$check_ids) . qq{) + AND ((security_map.group_id IS NULL OR security_map.group_id IN (} . $self->groups_as_string . qq{)) + OR (bugs.reporter_accessible = 1 AND bugs.reporter = $user_id) + OR (bugs.cclist_accessible = 1 AND security_cc.who IS NOT NULL) + OR bugs.assigned_to = $user_id + }; + + if (Bugzilla->params->{'useqacontact'}) { + $query .= " OR bugs.qa_contact = $user_id"; + } + $query .= ')'; + + $sth ||= $dbh->prepare($query); + if (scalar(@$check_ids) == 1) { + $self->{_sth_one_visible_bug} = $sth; + } + + # Set all bugs as non visible + foreach my $bug_id (@$check_ids) { + $visible_cache->{$bug_id} = 0; + } + + # Now get the bugs the user can see + my $visible_bug_ids = $dbh->selectcol_arrayref($sth, undef, @$check_ids); + foreach my $bug_id (@$visible_bug_ids) { + $visible_cache->{$bug_id} = 1; + } +} + +sub _visible_bugs_check_and { + my ($self, $check_ids) = @_; + my $visible_cache = $self->{_visible_bugs_cache}; + my $dbh = Bugzilla->dbh; + my $user_id = $self->id; + + my $sth; + # Speed up the can_see_bug case. + if (scalar(@$check_ids) == 1) { + $sth = $self->{_sth_one_visible_bug}; + } + $sth ||= $dbh->prepare( + # This checks for groups that the bug is in that the user + # *isn't* in. Then, in the Perl code below, we check if + # the user can otherwise access the bug (for example, by being + # the assignee or QA Contact). + # + # The DISTINCT exists because the bug could be in *several* + # groups that the user isn't in, but they will all return the + # same result for bug_group_map.bug_id (so DISTINCT filters + # out duplicate rows). + "SELECT DISTINCT bugs.bug_id, reporter, assigned_to, qa_contact, + reporter_accessible, cclist_accessible, cc.who, + bug_group_map.bug_id + FROM bugs + LEFT JOIN cc + ON cc.bug_id = bugs.bug_id + AND cc.who = $user_id + LEFT JOIN bug_group_map + ON bugs.bug_id = bug_group_map.bug_id + AND bug_group_map.group_id NOT IN (" + . $self->groups_as_string . ') + WHERE bugs.bug_id IN (' . join(',', ('?') x @$check_ids) . ') + AND creation_ts IS NOT NULL '); + if (scalar(@$check_ids) == 1) { + $self->{_sth_one_visible_bug} = $sth; + } + + $sth->execute(@$check_ids); + my $use_qa_contact = Bugzilla->params->{'useqacontact'}; + while (my $row = $sth->fetchrow_arrayref) { + my ($bug_id, $reporter, $owner, $qacontact, $reporter_access, + $cclist_access, $isoncclist, $missinggroup) = @$row; + $visible_cache->{$bug_id} ||= + ((($reporter == $user_id) && $reporter_access) + || ($use_qa_contact + && $qacontact && ($qacontact == $user_id)) + || ($owner == $user_id) + || ($isoncclist && $cclist_access) + || !$missinggroup) ? 1 : 0; + } + +} + sub clear_product_cache { my $self = shift; delete $self->{enterable_products}; @@ -964,15 +1289,24 @@ sub get_selectable_products { my $class_restricted = Bugzilla->params->{'useclassification'} && $class_id; if (!defined $self->{selectable_products}) { - my $query = "SELECT id " . - " FROM products " . - "LEFT JOIN group_control_map " . - "ON group_control_map.product_id = products.id " . - " AND group_control_map.membercontrol = " . CONTROLMAPMANDATORY . - " AND group_id NOT IN(" . $self->groups_as_string . ") " . - " WHERE group_id IS NULL " . - "ORDER BY name"; - + my $query = "SELECT id + FROM products + LEFT JOIN group_control_map + ON group_control_map.product_id = products.id + AND group_control_map.membercontrol = " . CONTROLMAPMANDATORY; + + if (Bugzilla->params->{'or_groups'}) { + # Either the user is in at least one of the MANDATORY groups, or + # there are no such groups for the product. + $query .= " WHERE group_id IN (" . $self->groups_as_string . ") + OR group_id IS NULL"; + } + else { + # There must be no MANDATORY groups that the user is not in. + $query .= " AND group_id NOT IN (" . $self->groups_as_string . ") + WHERE group_id IS NULL"; + } + my $prod_ids = Bugzilla->dbh->selectcol_arrayref($query); $self->{selectable_products} = Bugzilla::Product->new_from_list($prod_ids); } @@ -1065,14 +1399,21 @@ sub get_enterable_products { } # All products which the user has "Entry" access to. - my $enterable_ids = $dbh->selectcol_arrayref( + my $query = 'SELECT products.id FROM products - LEFT JOIN group_control_map - ON group_control_map.product_id = products.id - AND group_control_map.entry != 0 - AND group_id NOT IN (' . $self->groups_as_string . ') - WHERE group_id IS NULL - AND products.isactive = 1'); + LEFT JOIN group_control_map + ON group_control_map.product_id = products.id + AND group_control_map.entry != 0'; + + if (Bugzilla->params->{'or_groups'}) { + $query .= " WHERE (group_id IN (" . $self->groups_as_string . ")" . + " OR group_id IS NULL)"; + } else { + $query .= " AND group_id NOT IN (" . $self->groups_as_string . ")" . + " WHERE group_id IS NULL" + } + $query .= " AND products.isactive = 1"; + my $enterable_ids = $dbh->selectcol_arrayref($query); if (scalar @$enterable_ids) { # And all of these products must have at least one component @@ -1110,6 +1451,30 @@ sub get_accessible_products { return [ sort { $a->name cmp $b->name } values %products ]; } +sub can_administer { + my $self = shift; + + if (not defined $self->{can_administer}) { + my $can_administer = 0; + + $can_administer = 1 if $self->in_group('admin') + || $self->in_group('tweakparams') + || $self->in_group('editusers') + || $self->can_bless + || (Bugzilla->params->{'useclassification'} && $self->in_group('editclassifications')) + || $self->in_group('editcomponents') + || scalar(@{$self->get_products_by_permission('editcomponents')}) + || $self->in_group('creategroups') + || $self->in_group('editkeywords') + || $self->in_group('bz_canusewhines'); + + Bugzilla::Hook::process('user_can_administer', { can_administer => \$can_administer }); + $self->{can_administer} = $can_administer; + } + + return $self->{can_administer}; +} + sub check_can_admin_product { my ($self, $product_name) = @_; @@ -1143,7 +1508,7 @@ sub check_can_admin_flagtype { my $e = $flagtype->exclusions_as_hash; # If there is at least one product for which the user doesn't have - # editcomponents privs, then don't allow him to do everything with + # editcomponents privs, then don't allow them to do everything with # this flagtype, independently of whether this product is in the # exclusion list or not. my %product_ids; @@ -1297,6 +1662,8 @@ sub derive_regexp_groups { $group_delete->execute($id, $group, GRANT_REGEXP) if $present; } } + + Bugzilla->memcached->clear_config({ key => "user_groups.$id" }); } sub product_responsibilities { @@ -1832,6 +2199,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; @@ -2033,6 +2411,9 @@ sub check_and_send_account_creation_confirmation { ThrowUserError('account_creation_restricted'); } + # Allow extensions to do extra checks. + Bugzilla::Hook::process('user_check_account_creation', { login => $login }); + # Create and send a token for this new account. require Bugzilla::Token; Bugzilla::Token::issue_new_user_account_token($login); @@ -2072,41 +2453,36 @@ sub login_to_id { } } -sub user_id_to_login { - my $user_id = shift; - my $dbh = Bugzilla->dbh; - - return '' unless ($user_id && detaint_natural($user_id)); - - my $login = $dbh->selectrow_array('SELECT login_name FROM profiles - WHERE userid = ?', undef, $user_id); - return $login || ''; +sub validate_password { + my $check = validate_password_check(@_); + ThrowUserError($check) if $check; + return 1; } -sub validate_password { +sub validate_password_check { my ($password, $matchpassword) = @_; if (length($password) < USER_PASSWORD_MIN_LENGTH) { - ThrowUserError('password_too_short'); + return 'password_too_short'; } elsif ((defined $matchpassword) && ($password ne $matchpassword)) { - ThrowUserError('passwords_dont_match'); + return 'passwords_dont_match'; } - + my $complexity_level = Bugzilla->params->{password_complexity}; if ($complexity_level eq 'letters_numbers_specialchars') { - ThrowUserError('password_not_complex') + return 'password_not_complex' if ($password !~ /[[:alpha:]]/ || $password !~ /\d/ || $password !~ /[[:punct:]]/); } elsif ($complexity_level eq 'letters_numbers') { - ThrowUserError('password_not_complex') + return 'password_not_complex' if ($password !~ /[[:lower:]]/ || $password !~ /[[:upper:]]/ || $password !~ /\d/); } elsif ($complexity_level eq 'mixed_letters') { - ThrowUserError('password_not_complex') + return 'password_not_complex' if ($password !~ /[[:lower:]]/ || $password !~ /[[:upper:]]/); } # Having done these checks makes us consider the password untainted. trick_taint($_[0]); - return 1; + return; } @@ -2224,6 +2600,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 @@ -2276,6 +2680,10 @@ This notes that this account has failed to log in, and stores the fact in the database. The storing happens immediately, it does not wait for you to call C<update>. +=item C<set_email_enabled> + +C<bool> - Sets C<disable_mail> to the inverse of the boolean provided. + =back =head2 Other Methods @@ -2301,7 +2709,7 @@ Returns the 'real' name for this user, if any. =item C<showmybugslink> -Returns C<1> if the user has set his preference to show the 'My Bugs' link in +Returns C<1> if the user has set their preference to show the 'My Bugs' link in the page footer, and C<0> otherwise. =item C<identity> @@ -2405,7 +2813,7 @@ that you need to be able to see a group in order to bless it. =item C<get_products_by_permission($group)> Returns a list of product objects for which the user has $group privileges -and which he can access. +and which they can access. $group must be one of the groups defined in PER_PRODUCT_PRIVILEGES. =item C<can_see_user(user)> @@ -2418,6 +2826,12 @@ Returns 1 if the specified user account exists and is visible to the user, Determines if, given a product id, the user can edit bugs in this product at all. +=item C<visible_bugs($bugs)> + +Description: Determines if a list of bugs are visible to the user. +Params: C<$bugs> - An arrayref of Bugzilla::Bug objects or bug ids +Returns: An arrayref of the bug ids that the user can see + =item C<can_see_bug(bug_id)> Determines if the user can see the specified bug. @@ -2504,6 +2918,10 @@ not be aware of the existence of the product. Returns: an array of product objects. +=item C<can_administer> + +Returns 1 if the user can see the admin menu. Otherwise, returns 0 + =item C<check_can_admin_product($product_name)> Description: Checks whether the user is allowed to administrate the product. @@ -2517,7 +2935,7 @@ not be aware of the existence of the product. Description: Checks whether the user is allowed to edit properties of the flag type. If the flag type is also used by some products for which the user hasn't editcomponents privs, then the user is only allowed to edit - the inclusion and exclusion lists for products he can administrate. + the inclusion and exclusion lists for products they can administrate. Params: $flagtype_id - a flag type ID. @@ -2600,6 +3018,72 @@ 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. + +=item C<last_visited> + +Returns an arrayref L<Bugzilla::BugUserLastVisit> objects. + +=item C<is_involved_in_bug($bug)> + +Returns true if any of the following conditions are met, false otherwise. + +=over + +=item * + +User is the assignee of the bug + +=item * + +User is the reporter of the bug + +=item * + +User is the QA contact of the bug (if Bugzilla is configured to use a QA +contact) + +=item * + +User is in the cc list for the bug. + +=back + +=item C<set_groups> + +C<hash> These specify the groups that this user is directly a member of. +To set these, you should pass a hash as the value. The hash may contain +the following fields: + +=over + +=item C<add> An array of C<int>s or C<string>s. The group ids or group names +that the user should be added to. + +=item C<remove> An array of C<int>s or C<string>s. The group ids or group names +that the user should be removed from. + +=item C<set> An array of C<int>s or C<string>s. An exact set of group ids +and group names that the user should be a member of. NOTE: This does not +remove groups from the user where the person making the change does not +have the bless privilege for. + +If you specify C<set>, then C<add> and C<remove> will be ignored. A group in +both the C<add> and C<remove> list will be added. Specifying a group that the +user making the change does not have bless rights will generate an error. + +=back + +=item C<set_bless_groups> + +C<hash> - This is the same as set_groups, but affects what groups a user +has direct membership to bless that group. It takes the same inputs as +set_groups. + =back =head1 CLASS FUNCTIONS @@ -2620,7 +3104,7 @@ Params: login_name - B<Required> The login name for the new user. a plain-text password. If you specify '*', the user will not be able to log in using DB authentication. disabledtext - The disable-text for the new user. If given, the user - will be disabled, meaning he cannot log in. Defaults to an + will be disabled, meaning they cannot log in. Defaults to an empty string. disable_mail - If 1, bug-related mail will not be sent to this user; if 0, mail will be sent depending on the user's email preferences. @@ -2650,8 +3134,8 @@ Params: $username (scalar, string) - The full login name of the username that you are checking. $old_username (scalar, string) - If you are checking an email-change token, insert the "old" username that the user is changing from, - here. Then, as long as it's the right user for that token, he - can change his username to $username. (That is, this function + here. Then, as long as it's the right user for that token, they + can change their username to $username. (That is, this function will return a boolean true value). =item C<login_to_id($login, $throw_error)> @@ -2670,21 +3154,26 @@ of a user, but you don't want the full weight of Bugzilla::User. However, consider using a Bugzilla::User object instead of this function if you need more information about the user than just their ID. -=item C<user_id_to_login($user_id)> - -Returns the login name of the user account for the given user ID. If no -valid user ID is given or the user has no entry in the profiles table, -we return an empty string. - =item C<validate_password($passwd1, $passwd2)> Returns true if a password is valid (i.e. meets Bugzilla's -requirements for length and content), else returns false. +requirements for length and content), else throws an error. Untaints C<$passwd1> if successful. If a second password is passed in, this function also verifies that the two passwords match. +=item C<validate_password_check($passwd1, $passwd2)> + +This sub routine is similair to C<validate_password>, except that it allows +the calling code to handle its own errors. + +Returns undef and untaints C<$passwd1> if a password is valid (i.e. meets +Bugzilla's requirements for length and content), else returns the error. + +If a second password is passed in, this function also verifies that +the two passwords match. + =item C<match_field($data, $fields, $behavior)> =over @@ -2733,3 +3222,55 @@ is done with the data. =head1 SEE ALSO L<Bugzilla|Bugzilla> + +=head1 B<Methods in need of POD> + +=over + +=item email_enabled + +=item cryptpassword + +=item clear_login_failures + +=item set_disable_mail + +=item has_audit_entries + +=item groups_with_icon + +=item check_login_name + +=item set_extern_id + +=item mail_settings + +=item email_disabled + +=item update + +=item is_timetracker + +=item is_enabled + +=item queryshare_groups_as_string + +=item set_login + +=item set_password + +=item last_seen_date + +=item set_disabledtext + +=item update_last_seen_date + +=item set_name + +=item DB_COLUMNS + +=item extern_id + +=item UPDATE_COLUMNS + +=back diff --git a/Bugzilla/User/APIKey.pm b/Bugzilla/User/APIKey.pm new file mode 100644 index 000000000..d268a0a93 --- /dev/null +++ b/Bugzilla/User/APIKey.pm @@ -0,0 +1,155 @@ +# 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::User::APIKey; + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); + +use Bugzilla::User; +use Bugzilla::Util qw(generate_random_password trim); + +##################################################################### +# Overriden Constants that are used as methods +##################################################################### + +use constant DB_TABLE => 'user_api_keys'; +use constant DB_COLUMNS => qw( + id + user_id + api_key + description + revoked + last_used +); + +use constant UPDATE_COLUMNS => qw(description revoked last_used); +use constant VALIDATORS => { + api_key => \&_check_api_key, + description => \&_check_description, + revoked => \&Bugzilla::Object::check_boolean, +}; +use constant LIST_ORDER => 'id'; +use constant NAME_FIELD => 'api_key'; + +# turn off auditing and exclude these objects from memcached +use constant { AUDIT_CREATES => 0, + AUDIT_UPDATES => 0, + AUDIT_REMOVES => 0, + USE_MEMCACHED => 0 }; + +# Accessors +sub id { return $_[0]->{id} } +sub user_id { return $_[0]->{user_id} } +sub api_key { return $_[0]->{api_key} } +sub description { return $_[0]->{description} } +sub revoked { return $_[0]->{revoked} } +sub last_used { return $_[0]->{last_used} } + +# Helpers +sub user { + my $self = shift; + $self->{user} //= Bugzilla::User->new({name => $self->user_id, cache => 1}); + return $self->{user}; +} + +sub update_last_used { + my $self = shift; + my $timestamp = shift + || Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + $self->set('last_used', $timestamp); + $self->update; +} + +# Setters +sub set_description { $_[0]->set('description', $_[1]); } +sub set_revoked { $_[0]->set('revoked', $_[1]); } + +# Validators +sub _check_api_key { return generate_random_password(40); } +sub _check_description { return trim($_[1]) || ''; } +1; + +__END__ + +=head1 NAME + +Bugzilla::User::APIKey - Model for an api key belonging to a user. + +=head1 SYNOPSIS + + use Bugzilla::User::APIKey; + + my $api_key = Bugzilla::User::APIKey->new($id); + my $api_key = Bugzilla::User::APIKey->new({ name => $api_key }); + + # Class Functions + $user_api_key = Bugzilla::User::APIKey->create({ + description => $description, + }); + +=head1 DESCRIPTION + +This package handles Bugzilla User::APIKey. + +C<Bugzilla::User::APIKey> is an implementation of L<Bugzilla::Object>, and +thus provides all the methods of L<Bugzilla::Object> in addition to the methods +listed below. + +=head1 METHODS + +=head2 Accessor Methods + +=over + +=item C<id> + +The internal id of the api key. + +=item C<user> + +The Bugzilla::User object that this api key belongs to. + +=item C<user_id> + +The user id that this api key belongs to. + +=item C<api_key> + +The API key, which is a random string. + +=item C<description> + +An optional string that lets the user describe what a key is used for. +For example: "Dashboard key", "Application X key". + +=item C<revoked> + +If true, this api key cannot be used. + +=item C<last_used> + +The date that this key was last used. undef if never used. + +=item C<update_last_used> + +Updates the last used value to the current timestamp. This is updated even +if the RPC call resulted in an error. It is not updated when the description +or the revoked flag is changed. + +=item C<set_description> + +Sets the new description + +=item C<set_revoked> + +Sets the revoked flag + +=back diff --git a/Bugzilla/User/Setting.pm b/Bugzilla/User/Setting.pm index 6fadfb352..ea3bbfb54 100644 --- a/Bugzilla/User/Setting.pm +++ b/Bugzilla/User/Setting.pm @@ -8,13 +8,20 @@ package Bugzilla::User::Setting; +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent qw(Exporter); # Module stuff -@Bugzilla::User::Setting::EXPORT = qw(get_all_settings get_defaults - add_setting); +@Bugzilla::User::Setting::EXPORT = qw( + get_all_settings + get_defaults + add_setting + clear_settings_cache +); use Bugzilla::Error; use Bugzilla::Util qw(trick_taint get_text); @@ -157,15 +164,20 @@ sub get_all_settings { my $settings = {}; my $dbh = Bugzilla->dbh; - my $rows = $dbh->selectall_arrayref( - q{SELECT name, default_value, is_enabled, setting_value, subclass - FROM setting - LEFT JOIN profile_setting - ON setting.name = profile_setting.setting_name - AND profile_setting.user_id = ?}, undef, ($user_id)); + my $cache_key = "user_settings.$user_id"; + my $rows = Bugzilla->memcached->get_config({ key => $cache_key }); + if (!$rows) { + $rows = $dbh->selectall_arrayref( + q{SELECT name, default_value, is_enabled, setting_value, subclass + FROM setting + LEFT JOIN profile_setting + ON setting.name = profile_setting.setting_name + AND profile_setting.user_id = ?}, undef, ($user_id)); + Bugzilla->memcached->set_config({ key => $cache_key, data => $rows }); + } foreach my $row (@$rows) { - my ($name, $default_value, $is_enabled, $value, $subclass) = @$row; + my ($name, $default_value, $is_enabled, $value, $subclass) = @$row; my $is_default; @@ -177,13 +189,18 @@ sub get_all_settings { } $settings->{$name} = new Bugzilla::User::Setting( - $name, $user_id, $is_enabled, + $name, $user_id, $is_enabled, $default_value, $value, $is_default, $subclass); } return $settings; } +sub clear_settings_cache { + my ($user_id) = @_; + Bugzilla->memcached->clear_config({ key => "user_settings.$user_id" }); +} + sub get_defaults { my ($user_id) = @_; my $dbh = Bugzilla->dbh; @@ -366,6 +383,13 @@ Params: C<$setting_name> - string - the name of the setting C<$is_enabled> - boolean - if false, all users must use the global default Returns: nothing +=item C<clear_settings_cache($user_id)> + +Description: Clears cached settings data for the specified user. Must be + called after updating any user's setting. +Params: C<$user_id> - integer - the user id. +Returns: nothing + =begin private =item C<_setting_exists> diff --git a/Bugzilla/User/Setting/Lang.pm b/Bugzilla/User/Setting/Lang.pm index 71b01d62f..d980b7a92 100644 --- a/Bugzilla/User/Setting/Lang.pm +++ b/Bugzilla/User/Setting/Lang.pm @@ -7,9 +7,11 @@ package Bugzilla::User::Setting::Lang; +use 5.10.1; use strict; +use warnings; -use base qw(Bugzilla::User::Setting); +use parent qw(Bugzilla::User::Setting); use Bugzilla::Constants; diff --git a/Bugzilla/User/Setting/Skin.pm b/Bugzilla/User/Setting/Skin.pm index c75ce7568..7b0688c0c 100644 --- a/Bugzilla/User/Setting/Skin.pm +++ b/Bugzilla/User/Setting/Skin.pm @@ -8,9 +8,11 @@ package Bugzilla::User::Setting::Skin; +use 5.10.1; use strict; +use warnings; -use base qw(Bugzilla::User::Setting); +use parent qw(Bugzilla::User::Setting); use Bugzilla::Constants; use File::Spec::Functions; diff --git a/Bugzilla/User/Setting/Timezone.pm b/Bugzilla/User/Setting/Timezone.pm index 91f997774..8959d1dda 100644 --- a/Bugzilla/User/Setting/Timezone.pm +++ b/Bugzilla/User/Setting/Timezone.pm @@ -7,11 +7,13 @@ package Bugzilla::User::Setting::Timezone; +use 5.10.1; use strict; +use warnings; use DateTime::TimeZone; -use base qw(Bugzilla::User::Setting); +use parent qw(Bugzilla::User::Setting); use Bugzilla::Constants; diff --git a/Bugzilla/UserAgent.pm b/Bugzilla/UserAgent.pm index 5615f86ee..6ceb9d3c5 100644 --- a/Bugzilla/UserAgent.pm +++ b/Bugzilla/UserAgent.pm @@ -7,8 +7,11 @@ package Bugzilla::UserAgent; +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent qw(Exporter); our @EXPORT = qw(detect_platform detect_op_sys); use Bugzilla::Field; diff --git a/Bugzilla/Util.pm b/Bugzilla/Util.pm index 4bd10e16c..670f5f8f2 100644 --- a/Bugzilla/Util.pm +++ b/Bugzilla/Util.pm @@ -7,21 +7,23 @@ package Bugzilla::Util; +use 5.10.1; use strict; +use warnings; -use base qw(Exporter); +use parent qw(Exporter); @Bugzilla::Util::EXPORT = qw(trick_taint detaint_natural detaint_signed html_quote url_quote xml_quote css_class_quote html_light_quote i_am_cgi i_am_webservice correct_urlbase remote_ip validate_ip do_ssl_redirect_if_required use_attachbase - diff_arrays on_main_db say + diff_arrays on_main_db trim wrap_hard wrap_comment find_wrap_point format_time validate_date validate_time datetime_from is_7bit_clean bz_crypt generate_random_password validate_email_syntax check_email_syntax clean_text - get_text template_var disable_utf8 - detect_encoding + get_text template_var display_value disable_utf8 + detect_encoding email_filter join_activity_entries); use Bugzilla::Constants; @@ -34,7 +36,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; @@ -64,10 +65,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 control characters if the encoding is utf8. # Other multibyte encodings may be using this range; so ignore if not utf8. $var =~ s/(?![\t\r\n])[[:cntrl:]]//g; @@ -93,7 +101,7 @@ 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; } @@ -237,7 +245,8 @@ sub i_am_cgi { 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_JSON + || $usage_mode == USAGE_MODE_REST; } # This exists as a separate function from Bugzilla::CGI::redirect_to_https @@ -418,13 +427,6 @@ sub diff_arrays { return (\@removed, \@added); } -# XXX - This is a temporary subroutine till we require Perl 5.10.1. -# This will happen before Bugzilla 5.0rc1. -sub say (@) { - print @_; - print "\n"; -} - sub trim { my ($str) = @_; if ($str) { @@ -451,11 +453,6 @@ sub wrap_comment { $wrappedcomment .= ($line . "\n"); } else { - # Due to a segfault in Text::Tabs::expand() when processing tabs with - # Unicode (see http://rt.perl.org/rt3/Public/Bug/Display.html?id=52104), - # we have to remove tabs before processing the comment. This restriction - # can go away when we require Perl 5.8.9 or newer. - $line =~ s/\t/ /g; $wrappedcomment .= (wrap('', '', $line) . "\n"); } } @@ -555,9 +552,14 @@ sub datetime_from { # In the database, this is the "0" date. return undef if $date =~ /^0000/; - # strptime($date) returns an empty array if $date has an invalid - # date format. - my @time = strptime($date); + my @time; + # Most dates will be in this format, avoid strptime's generic parser + if ($date =~ /^(\d{4})[\.-](\d{2})[\.-](\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/) { + @time = ($6, $5, $4, $3, $2 - 1, $1 - 1900, undef); + } + else { + @time = strptime($date); + } unless (scalar @time) { # If an unknown timezone is passed (such as MSK, for Moskow), @@ -641,20 +643,13 @@ sub bz_crypt { if (!$algorithm) { # Crypt the password. $crypted_password = crypt($password, $salt); - - # HACK: Perl has bug where returned crypted password is considered - # tainted. See http://rt.perl.org/rt3/Public/Bug/Display.html?id=59998 - unless(tainted($password) || tainted($salt)) { - trick_taint($crypted_password); - } } else { my $hasher = Digest->new($algorithm); - # We only want to use the first characters of the salt, no - # matter how long of a salt we may have been passed. - $salt = substr($salt, 0, PASSWORD_SALT_LENGTH); + # Newly created salts won't yet have a comma. + ($salt) = $salt =~ /^([^,]+),?/; $hasher->add($password, $salt); - $crypted_password = $salt . $hasher->b64digest . "{$algorithm}"; + $crypted_password = $salt . ',' . $hasher->b64digest . "{$algorithm}"; } # Return the crypted password. @@ -767,10 +762,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. @@ -784,11 +781,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 { @@ -1007,7 +1000,7 @@ in a command-line script. =item C<i_am_webservice()> Tells you whether or not the current usage mode is WebServices related -such as JSONRPC or XMLRPC. +such as JSONRPC, XMLRPC, or REST. =item C<correct_urlbase()> @@ -1152,7 +1145,7 @@ template. Just pass in the name of the variable that you want the value of. Takes a time and converts it to the desired format and timezone. If no format is given, the routine guesses the correct one and returns an empty array if it cannot. If no timezone is given, the user's timezone -is used, as defined in his preferences. +is used, as defined in their preferences. This routine is mainly called from templates to filter dates, see "FILTER time" in L<Bugzilla::Template>. @@ -1238,3 +1231,19 @@ if Bugzilla is currently using the shadowdb or not. Used like: } =back + +=head1 B<Methods in need of POD> + +=over + +=item do_ssl_redirect_if_required + +=item validate_time + +=item is_ipv4 + +=item is_ipv6 + +=item display_value + +=back diff --git a/Bugzilla/Version.pm b/Bugzilla/Version.pm index 7c341b654..4b332ff2b 100644 --- a/Bugzilla/Version.pm +++ b/Bugzilla/Version.pm @@ -5,13 +5,16 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. +package Bugzilla::Version; + +use 5.10.1; use strict; +use warnings; -package Bugzilla::Version; +use parent qw(Bugzilla::Object Exporter); -use base qw(Bugzilla::Object); +@Bugzilla::Version::EXPORT = qw(vers_cmp); -use Bugzilla::Install::Util qw(vers_cmp); use Bugzilla::Util; use Bugzilla::Error; @@ -172,8 +175,8 @@ sub product { # Validators ################################ -sub set_name { $_[0]->set('value', $_[1]); } -sub set_is_active { $_[0]->set('isactive', $_[1]); } +sub set_value { $_[0]->set('value', $_[1]); } +sub set_isactive { $_[0]->set('isactive', $_[1]); } sub _check_value { my ($invocant, $name, undef, $params) = @_; @@ -199,6 +202,53 @@ sub _check_product { return Bugzilla->user->check_can_admin_product($product->name); } +############################### +##### Functions #### +############################### + +# This is taken straight from Sort::Versions 1.5, which is not included +# with perl by default. +sub vers_cmp { + my ($a, $b) = @_; + + # Remove leading zeroes - Bug 344661 + $a =~ s/^0*(\d.+)/$1/; + $b =~ s/^0*(\d.+)/$1/; + + my @A = ($a =~ /([-.]|\d+|[^-.\d]+)/g); + my @B = ($b =~ /([-.]|\d+|[^-.\d]+)/g); + + my ($A, $B); + while (@A and @B) { + $A = shift @A; + $B = shift @B; + if ($A eq '-' and $B eq '-') { + next; + } elsif ( $A eq '-' ) { + return -1; + } elsif ( $B eq '-') { + return 1; + } elsif ($A eq '.' and $B eq '.') { + next; + } elsif ( $A eq '.' ) { + return -1; + } elsif ( $B eq '.' ) { + return 1; + } elsif ($A =~ /^\d+$/ and $B =~ /^\d+$/) { + if ($A =~ /^0/ || $B =~ /^0/) { + return $A cmp $B if $A cmp $B; + } else { + return $A <=> $B if $A <=> $B; + } + } else { + $A = uc $A; + $B = uc $B; + return $A cmp $B if $A cmp $B; + } + } + return @A <=> @B; +} + 1; __END__ @@ -222,7 +272,7 @@ Bugzilla::Version - Bugzilla product version class. my $version = Bugzilla::Version->create( { value => $name, product => $product_obj }); - $version->set_name($new_name); + $version->set_value($new_name); $version->update(); $version->remove_from_db; @@ -242,12 +292,74 @@ below. =item C<bug_count()> - Description: Returns the total of bugs that belong to the version. +=over + +=item B<Description> - Params: none. +Returns the total of bugs that belong to the version. - Returns: Integer with the number of bugs. +=item B<Params> + +none + +=item B<Returns> + +Integer with the number of bugs. + +=back + +=back + +=head1 FUNCTIONS + +=over + +=item C<vers_cmp($a, $b)> + +=over + +=item B<Description> + +This is a comparison function, like you would use in C<sort>, except that +it compares two version numbers. So, for example, 2.10 would be greater +than 2.2. + +It's based on versioncmp from L<Sort::Versions>, with some Bugzilla-specific +fixes. + +=item B<Params> + +C<$a> and C<$b> - The versions you want to compare. + +=item B<Returns> + +C<-1> if C<$a> is less than C<$b>, C<0> if they are equal, or C<1> if C<$a> +is greater than C<$b>. + +=back =back =cut + +=head1 B<Methods in need of POD> + +=over + +=item DEFAULT_VERSION + +=item set_isactive + +=item set_value + +=item product_id + +=item is_active + +=item remove_from_db + +=item product + +=item update + +=back diff --git a/Bugzilla/WebService.pm b/Bugzilla/WebService.pm index 5646e381d..f80813744 100644 --- a/Bugzilla/WebService.pm +++ b/Bugzilla/WebService.pm @@ -8,7 +8,11 @@ # This is the base class for $self in WebService method calls. For the # actual RPC server, see Bugzilla::WebService::Server and its subclasses. package Bugzilla::WebService; + +use 5.10.1; use strict; +use warnings; + use Bugzilla::WebService::Server; # Used by the JSON-RPC server to convert incoming date fields apprpriately. @@ -46,15 +50,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: @@ -73,6 +82,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. @@ -125,14 +139,22 @@ how this is implemented for those frontends. =head1 LOGGING IN -There are various ways to log in: +Some methods do not require you to log in. An example of this is Bug.get. +However, authenticating yourself allows you to see non public information. For +example, a bug that is not publicly visible. + +There are two ways to authenticate yourself: =over -=item C<User.login> +=item C<Bugzilla_api_key> -You can use L<Bugzilla::WebService::User/login> to log in as a Bugzilla -user. This issues a token that you must then use in future calls. +B<Added in Bugzilla 5.0> + +You can specify C<Bugzilla_api_key> as an argument to any WebService method, and +you will be logged in as that user if the key is correct, and has not been +revoked. You can set up an API key by using the 'API Key' tab in the +Preferences pages. =item C<Bugzilla_login> and C<Bugzilla_password> @@ -155,18 +177,39 @@ then your login will only be valid for your IP address. =back The C<Bugzilla_restrictlogin> option is only used when you have also -specified C<Bugzilla_login> and C<Bugzilla_password>. +specified C<Bugzilla_login> and C<Bugzilla_password>. This value will be +deprecated in the release after Bugzilla 5.0 and you will be required to +pass the Bugzilla_login and Bugzilla_password for every call. + +For REST, you may also use the C<login> and C<password> variable +names instead of C<Bugzilla_login> and C<Bugzilla_password> as a +convenience. You may also use C<token> instead of C<Bugzilla_token>. + +=back + +There are also two deprecreated methods of authentications. This will be +removed in the version after Bugzilla 5.0. + +=over + +=item C<User.login> + +You can use L<Bugzilla::WebService::User/login> to log in as a Bugzilla +user. This issues a token that you must then use in future calls. =item C<Bugzilla_token> -B<Added in Bugzilla 5.0 and backported to 4.4.3> +B<Added in Bugzilla 4.4.3> You can specify C<Bugzilla_token> as argument to any WebService method, and you will be logged in as that user if the token is correct. This is the token returned when calling C<User.login> mentioned above. -Support for using login cookies for authentication has been dropped -for security reasons. +An error is thrown if you pass an invalid token and you will need to log +in again to get a new token. + +Token support was added in Bugzilla B<5.0> and support for login cookies +has been dropped for security reasons. =back @@ -262,6 +305,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 @@ -291,6 +337,37 @@ 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 + +There are several shortcut identifiers to ask for only certain groups of +fields to be returned or excluded. + +=over + +=item C<_all> + +All possible fields are returned if C<_all> is specified in C<include_fields>. + +=item C<_default> + +These fields are returned if C<include_fields> is empty or C<_default> +is specified. All fields described in the documentation are returned +by default unless specified otherwise. + +=item C<_extra> + +These fields are not returned by default and need to be manually specified +in C<include_fields> either by field name, or using C<_extra>. + +=item C<_custom> + +Only custom fields are returned if C<_custom> is specified in C<include_fields>. +This is normally specific to bug objects and not relevant for other returned +objects. + =back =head1 SEE ALSO @@ -315,6 +392,10 @@ would return something like: =item L<Bugzilla::WebService::Classification> +=item L<Bugzilla::WebService::FlagType> + +=item L<Bugzilla::WebService::Component> + =item L<Bugzilla::WebService::Group> =item L<Bugzilla::WebService::Product> @@ -322,3 +403,11 @@ would return something like: =item L<Bugzilla::WebService::User> =back + +=head1 B<Methods in need of POD> + +=over + +=item login_exempt + +=back diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm index 006925994..127ea40bb 100644 --- a/Bugzilla/WebService/Bug.pm +++ b/Bugzilla/WebService/Bug.pm @@ -7,22 +7,34 @@ package Bugzilla::WebService::Bug; +use 5.10.1; use strict; -use base qw(Bugzilla::WebService); +use warnings; + +use parent 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(extract_flags filter filter_wants validate translate); use Bugzilla::Bug; use Bugzilla::BugMail; -use Bugzilla::Util qw(trick_taint trim diff_arrays); +use Bugzilla::Util qw(trick_taint trim diff_arrays detaint_natural); use Bugzilla::Version; use Bugzilla::Milestone; use Bugzilla::Status; use Bugzilla::Token qw(issue_hash_token); +use Bugzilla::Search; +use Bugzilla::Product; +use Bugzilla::FlagType; +use Bugzilla::Search::Quicksearch; + +use List::Util qw(max); +use List::MoreUtils qw(uniq); +use Storable qw(dclone); ############# # Constants # @@ -32,6 +44,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'], }; @@ -57,28 +70,32 @@ use constant PUBLIC_METHODS => qw( create fields get - get_bugs - get_history history legal_values possible_duplicates render_comment search + search_comment_tags update + update_attachment + update_comment_tags update_see_also update_tags ); -###################################################### -# Add aliases here for old method name compatibility # -###################################################### +use constant ATTACHMENT_MAPPED_SETTERS => { + file_name => 'filename', + summary => 'description', +}; -BEGIN { - # In 3.0, get was called get_bugs - *get_bugs = \&get; - # Before 3.4rc1, "history" was get_history. - *get_history = \&history; -} +use constant ATTACHMENT_MAPPED_RETURNS => { + description => 'summary', + ispatch => 'is_patch', + isprivate => 'is_private', + isobsolete => 'is_obsolete', + filename => 'file_name', + mimetype => 'content_type', +}; ########### # Methods # @@ -312,16 +329,40 @@ sub comments { return { bugs => \%bugs, comments => \%comments }; } +sub render_comment { + my ($self, $params) = @_; + + unless (defined $params->{text}) { + ThrowCodeError('params_required', + { function => 'Bug.render_comment', + params => ['text'] }); + } + + Bugzilla->switch_to_shadow_db(); + my $bug = $params->{id} ? Bugzilla::Bug->check($params->{id}) : undef; + + my $tmpl = '[% text FILTER quoteUrls(bug) %]'; + my $html; + my $template = Bugzilla->template; + $template->process( + \$tmpl, + { bug => $bug, text => $params->{text}}, + \$html + ); + + return { html => $html }; +} + # Helper for Bug.comments sub _translate_comment { - my ($self, $comment, $filters) = @_; + my ($self, $comment, $filters, $types, $prefix) = @_; 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), time => $self->type('dateTime', $comment->creation_ts), creation_time => $self->type('dateTime', $comment->creation_ts), is_private => $self->type('boolean', $comment->is_private), @@ -329,18 +370,33 @@ sub _translate_comment { attachment_id => $self->type('int', $attach_id), count => $self->type('int', $comment->count), }; + + # 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, $types, $prefix); } sub get { my ($self, $params) = validate(@_, 'ids'); - Bugzilla->switch_to_shadow_db(); + Bugzilla->switch_to_shadow_db() unless Bugzilla->user->id; my $ids = $params->{ids}; defined $ids || ThrowCodeError('param_required', { param => 'ids' }); - my @bugs; - my @faults; + 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); + foreach my $bug_id (@$ids) { my $bug; if ($params->{permissive}) { @@ -358,10 +414,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(\@hashes); + + $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 @@ -385,7 +449,7 @@ sub history { $bug_id = $bug->id; $item{id} = $self->type('int', $bug_id); - my ($activity) = $bug->get_activity; + my ($activity) = $bug->get_activity(undef, $params->{new_since}); my @history; foreach my $changeset (@$activity) { @@ -414,7 +478,7 @@ sub history { # alias is returned in case users passes a mixture of ids and aliases # then they get to know which bug activity relates to which value # they passed - $item{alias} = $self->type('string', $bug->alias); + $item{alias} = [ map { $self->type('string', $_) } @{ $bug->alias } ]; push(@return, \%item); } @@ -424,77 +488,110 @@ sub history { sub search { my ($self, $params) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; Bugzilla->switch_to_shadow_db(); - if ( defined($params->{offset}) and !defined($params->{limit}) ) { - ThrowCodeError('param_required', + 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($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 $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++; } - if (my $when = delete $params->{creation_ts}) { - $params->{WHERE}->{'creation_ts >= ?'} = $when; + + # 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; + + 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 ($self, $params) = validate(@_, 'products'); my $user = Bugzilla->user; Bugzilla->switch_to_shadow_db(); @@ -504,7 +601,7 @@ sub possible_duplicates { { function => 'Bug.possible_duplicates', param => 'summary' }); my @products; - foreach my $name (@{ $params->{'product'} || [] }) { + foreach my $name (@{ $params->{'products'} || [] }) { my $object = $user->can_enter_product($name, THROW_ERROR); push(@products, $object); } @@ -513,6 +610,7 @@ 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 }; } @@ -543,10 +641,25 @@ sub update { # have valid "set_" functions in Bugzilla::Bug, but shouldn't be # called using those field names. delete $values{dependencies}; - delete $values{flags}; + + # For backwards compatibility, treat alias string or array as a set action + if (exists $values{alias}) { + if (not ref $values{alias}) { + $values{alias} = { set => [ $values{alias} ] }; + } + elsif (ref $values{alias} eq 'ARRAY') { + $values{alias} = { set => $values{alias} }; + } + } + + my $flags = delete $values{flags}; foreach my $bug (@bugs) { $bug->set_all(\%values); + if ($flags) { + my ($old_flags, $new_flags) = extract_flags($flags, $bug); + $bug->set_flags($old_flags, $new_flags); + } } my %all_changes; @@ -555,7 +668,7 @@ sub update { $all_changes{$bug->id} = $bug->update(); } $dbh->bz_commit_transaction(); - + foreach my $bug (@bugs) { $bug->send_changes($all_changes{$bug->id}); } @@ -576,7 +689,7 @@ sub update { # alias is returned in case users pass a mixture of ids and aliases, # so that they can know which set of changes relates to which value # they passed. - $hash{alias} = $self->type('string', $bug->alias); + $hash{alias} = [ map { $self->type('string', $_) } @{ $bug->alias } ]; my %changes = %{ $all_changes{$bug->id} }; foreach my $field (keys %changes) { @@ -601,10 +714,31 @@ sub update { sub create { my ($self, $params) = @_; + my $dbh = Bugzilla->dbh; + Bugzilla->login(LOGIN_REQUIRED); + $params = Bugzilla::Bug::map_fields($params); + + my $flags = delete $params->{flags}; + + # We start a nested transaction in case flag setting fails + # we want the bug creation to roll back as well. + $dbh->bz_start_transaction(); + my $bug = Bugzilla::Bug->create($params); - Bugzilla::BugMail::Send($bug->bug_id, { changer => $bug->reporter }); + + # Set bug flags + if ($flags) { + my ($flags, $new_flags) = extract_flags($flags, $bug); + $bug->set_flags($flags, $new_flags); + $bug->update($bug->creation_ts); + } + + $dbh->bz_commit_transaction(); + + $bug->send_changes(); + return { id => $self->type('int', $bug->bug_id) }; } @@ -677,6 +811,8 @@ sub add_attachment { $dbh->bz_start_transaction(); my $timestamp = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + my $flags = delete $params->{flags}; + foreach my $bug (@bugs) { my $attachment = Bugzilla::Attachment->create({ bug => $bug, @@ -688,6 +824,13 @@ sub add_attachment { ispatch => $params->{is_patch}, isprivate => $params->{is_private}, }); + + if ($flags) { + my ($old_flags, $new_flags) = extract_flags($flags, $bug, $attachment); + $attachment->set_flags($old_flags, $new_flags); + } + + $attachment->update($timestamp); my $comment = $params->{comment} || ''; $attachment->bug->add_comment($comment, { isprivate => $attachment->isprivate, @@ -705,6 +848,119 @@ sub add_attachment { return { ids => \@created_ids }; } +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}; + } + + $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; + + push @attachments, $attachment; + $bugs{$bug->id} = $bug; + } + + my $flags = delete $params->{flags}; + my $comment = delete $params->{comment}; + + # Update the values + foreach my $attachment (@attachments) { + my ($update_flags, $new_flags) = $flags + ? extract_flags($flags, $attachment->bug, $attachment) + : ([], []); + if ($attachment->validate_can_edit) { + $attachment->set_all($params); + $attachment->set_flags($update_flags, $new_flags) if $flags; + } + elsif (scalar @$update_flags && !scalar(@$new_flags) && !scalar keys %$params) { + # Requestees can set flags targetted to them, even if they cannot + # edit the attachment. Flag setters can edit their own flags too. + my %flag_list = map { $_->{id} => $_ } @$update_flags; + my $flag_objs = Bugzilla::Flag->new_from_list([ keys %flag_list ]); + my @editable_flags; + foreach my $flag_obj (@$flag_objs) { + if ($flag_obj->setter_id == $user->id + || ($flag_obj->requestee_id && $flag_obj->requestee_id == $user->id)) + { + push(@editable_flags, $flag_list{$flag_obj->id}); + } + } + if (!scalar @editable_flags) { + ThrowUserError("illegal_attachment_edit", { attach_id => $attachment->id }); + } + $attachment->set_flags(\@editable_flags, []); + } + else { + ThrowUserError("illegal_attachment_edit", { attach_id => $attachment->id }); + } + } + + $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(); + + if ($comment = trim($comment)) { + $attachment->bug->add_comment($comment, + { isprivate => $attachment->isprivate, + type => CMT_ATTACHMENT_UPDATED, + extra_data => $attachment->id }); + } + + $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) { + $bug->update(); + $bug->send_changes(); + } + + # Return the information to the user + return { attachments => \@result }; +} + sub add_comment { my ($self, $params) = @_; @@ -784,7 +1040,7 @@ sub update_see_also { sub attachments { my ($self, $params) = validate(@_, 'ids', 'attachment_ids'); - Bugzilla->switch_to_shadow_db(); + Bugzilla->switch_to_shadow_db() unless Bugzilla->user->id; if (!(defined $params->{ids} or defined $params->{attachment_ids})) @@ -860,6 +1116,73 @@ sub update_tags { return { changes => \%changes }; } +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 = $params->{limit} || 7; + detaint_natural($limit) + || ThrowCodeError('param_must_be_numeric', { param => 'limit', + function => 'Bug.search_comment_tags' }); + + + my $tags = Bugzilla::Comment::TagWeights->match({ + WHERE => { + 'tag LIKE ?' => "\%$query\%", + }, + LIMIT => $limit, + }); + return [ map { $_->tag } @$tags ]; +} + ############################## # Private Helper Subroutines # ############################## @@ -875,12 +1198,12 @@ sub _bug_to_hash { # All the basic bug attributes are here, in alphabetical order. # A bug attribute is "basic" if it doesn't require an additional # database call to get the info. - my %item = ( - alias => $self->type('string', $bug->alias), - creation_time => $self->type('dateTime', $bug->creation_ts), + my %item = %{ filter $params, { + # No need to format $bug->deadline specially, because Bugzilla::Bug + # already does it for us. + deadline => $self->type('string', $bug->deadline), id => $self->type('int', $bug->bug_id), is_confirmed => $self->type('boolean', $bug->everconfirmed), - last_change_time => $self->type('dateTime', $bug->delta_ts), op_sys => $self->type('string', $bug->op_sys), platform => $self->type('string', $bug->rep_platform), priority => $self->type('string', $bug->priority), @@ -892,14 +1215,16 @@ sub _bug_to_hash { url => $self->type('string', $bug->bug_file_loc), version => $self->type('string', $bug->version), whiteboard => $self->type('string', $bug->status_whiteboard), - ); - + } }; - # First we handle any fields that require extra SQL calls. - # We don't do the SQL calls at all if the filter would just - # eliminate them anyway. + # First we handle any fields that require extra work (such as date parsing + # or SQL calls). + if (filter_wants $params, 'alias') { + $item{alias} = [ map { $self->type('string', $_) } @{ $bug->alias } ]; + } 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, undef, 'assigned_to'); } if (filter_wants $params, 'blocks') { my @blocks = map { $self->type('int', $_) } @{ $bug->blocked }; @@ -912,11 +1237,16 @@ sub _bug_to_hash { $item{component} = $self->type('string', $bug->component); } 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, undef, 'cc') } @{ $bug->cc_users } ]; + } + if (filter_wants $params, 'creation_time') { + $item{'creation_time'} = $self->type('dateTime', $bug->creation_ts); } 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, undef, 'creator'); } if (filter_wants $params, 'depends_on') { my @depends_on = map { $self->type('int', $_) } @{ $bug->dependson }; @@ -938,12 +1268,18 @@ sub _bug_to_hash { @{ $bug->keyword_objects }; $item{'keywords'} = \@keywords; } + if (filter_wants $params, 'last_change_time') { + $item{'last_change_time'} = $self->type('dateTime', $bug->delta_ts); + } if (filter_wants $params, 'product') { $item{product} = $self->type('string', $bug->product); } 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, undef, 'qa_contact'); + } } if (filter_wants $params, 'see_also') { my @see_also = map { $self->type('string', $_->name) } @@ -953,16 +1289,21 @@ sub _bug_to_hash { if (filter_wants $params, 'flags') { $item{'flags'} = [ map { $self->_flag_to_hash($_) } @{$bug->flags} ]; } + if (filter_wants $params, 'tags', 'extra') { + $item{'tags'} = $bug->tags; + } # And now custom fields my @custom_fields = Bugzilla->active_custom_fields; foreach my $field (@custom_fields) { my $name = $field->name; - next if !filter_wants $params, $name; + next if !filter_wants($params, $name, ['default', 'custom']); 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) { @@ -976,34 +1317,42 @@ sub _bug_to_hash { # Timetracking fields are only sent if the user can see them. if (Bugzilla->user->is_timetracker) { - $item{'estimated_time'} = $self->type('double', $bug->estimated_time); - $item{'remaining_time'} = $self->type('double', $bug->remaining_time); - # No need to format $bug->deadline specially, because Bugzilla::Bug - # already does it for us. - $item{'deadline'} = $self->type('string', $bug->deadline); - + if (filter_wants $params, 'estimated_time') { + $item{'estimated_time'} = $self->type('double', $bug->estimated_time); + } + if (filter_wants $params, 'remaining_time') { + $item{'remaining_time'} = $self->type('double', $bug->remaining_time); + } if (filter_wants $params, 'actual_time') { $item{'actual_time'} = $self->type('double', $bug->actual_time); } } - if (Bugzilla->user->id) { - my $token = issue_hash_token([$bug->id, $bug->delta_ts]); - $item{'update_token'} = $self->type('string', $token); - } - # The "accessible" bits go here because they have long names and it # makes the code look nicer to separate them out. - $item{'is_cc_accessible'} = $self->type('boolean', - $bug->cclist_accessible); - $item{'is_creator_accessible'} = $self->type('boolean', - $bug->reporter_accessible); + if (filter_wants $params, 'is_cc_accessible') { + $item{'is_cc_accessible'} = $self->type('boolean', $bug->cclist_accessible); + } + if (filter_wants $params, 'is_creator_accessible') { + $item{'is_creator_accessible'} = $self->type('boolean', $bug->reporter_accessible); + } + + return \%item; +} - return filter $params, \%item; +sub _user_to_hash { + my ($self, $user, $filters, $types, $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), + }, $types, $prefix; + return $item; } sub _attachment_to_hash { - my ($self, $attach, $filters) = @_; + my ($self, $attach, $filters, $types, $prefix) = @_; my $item = filter $filters, { creation_time => $self->type('dateTime', $attach->attached), @@ -1012,30 +1361,27 @@ sub _attachment_to_hash { bug_id => $self->type('int', $attach->bug_id), file_name => $self->type('string', $attach->filename), summary => $self->type('string', $attach->description), - description => $self->type('string', $attach->description), content_type => $self->type('string', $attach->contenttype), is_private => $self->type('int', $attach->isprivate), is_obsolete => $self->type('int', $attach->isobsolete), is_patch => $self->type('int', $attach->ispatch), - }; + }, $types, $prefix; - # creator/attacher require an extra lookup, so we only send them if + # creator requires an extra lookup, so we only send them if # the filter wants them. - foreach my $field (qw(creator attacher)) { - if (filter_wants $filters, $field) { - $item->{$field} = $self->type('string', $attach->attacher->login); - } + if (filter_wants $filters, 'creator', $types, $prefix) { + $item->{'creator'} = $self->type('email', $attach->attacher->login); } - if (filter_wants $filters, 'data') { + if (filter_wants $filters, 'data', $types, $prefix) { $item->{'data'} = $self->type('base64', $attach->data); } - if (filter_wants $filters, 'size') { + if (filter_wants $filters, 'size', $types, $prefix) { $item->{'size'} = $self->type('int', $attach->datasize); } - if (filter_wants $filters, 'flags') { + if (filter_wants $filters, 'flags', $types, $prefix) { $item->{'flags'} = [ map { $self->_flag_to_hash($_) } @{$attach->flags} ]; } @@ -1056,13 +1402,25 @@ sub _flag_to_hash { foreach my $field (qw(setter requestee)) { my $field_id = $field . "_id"; - $item->{$field} = $self->type('string', $flag->$field->login) + $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__ @@ -1082,6 +1440,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 @@ -1095,11 +1457,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 /rest/field/bug + +To get information related to a single field: + +GET /rest/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 @@ -1147,6 +1524,12 @@ C<int> The number of the fieldtype. The following values are defined: =item C<7> Bug URLs ("See Also") +=item C<8> Keywords + +=item C<9> Date + +=item C<10> Integer value + =back =item C<is_custom> @@ -1295,10 +1678,11 @@ You specified an invalid field name or id. =item C<is_active> return key for C<values> was added in Bugzilla B<4.4>. -=back +=item REST API call added in Bugzilla B<5.0> =back +=back =head2 legal_values @@ -1310,6 +1694,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 /rest/field/bug/<field_name>/values + +To get information based on field name and a specific product: + +GET /rest/field/bug/<field_name>/<product_id>/values + +The returned data format is the same as below. + =item B<Params> =over @@ -1342,6 +1738,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 @@ -1360,6 +1764,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 /rest/bug/<bug_id>/attachment + +To get a specific attachment based on attachment ID: + +GET /rest/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. @@ -1448,10 +1864,6 @@ C<string> The file name of the attachment. C<string> A short string describing the attachment. -Also returned as C<description>, for backwards-compatibility with older -Bugzillas. (However, this backwards-compatibility will go away in Bugzilla -5.0.) - =item C<content_type> C<string> The MIME type of the attachment. @@ -1473,10 +1885,6 @@ C<boolean> True if the attachment is a patch, False otherwise. C<string> The login name of the user that created the attachment. -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 @@ -1557,6 +1965,8 @@ C<summary>. =item The C<flags> array was added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -1573,6 +1983,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 /rest/bug/<id_or_alias>/comment + +To get a specific comment based on the comment ID: + +GET /rest/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. @@ -1660,10 +2082,6 @@ C<string> The actual text of the comment. C<string> The login name of the comment's author. -Also returned as C<author>, for backwards-compatibility with older -Bugzillas. (However, this backwards-compatibility will go away in Bugzilla -5.0.) - =item time C<dateTime> The time (in Bugzilla's timezone) that the comment was added. @@ -1718,6 +2136,8 @@ C<creator>. =item C<creation_time> was added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -1733,7 +2153,13 @@ B<STABLE> 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 /rest/bug/<id_or_alias> + +The returned data format is the same as below. =item B<Params> @@ -1773,6 +2199,9 @@ Two items are returned: An array of hashes that contains information about the bugs with the valid ids. Each hash contains the following items: +These fields are returned by default or by specifying C<_default> +in C<include_fields>. + =over =item C<actual_time> @@ -1784,12 +2213,18 @@ in the return value. =item C<alias> -C<string> The unique alias of this bug. +C<array> of C<string>s The unique aliases of this bug. An empty array will be +returned if this bug has no aliases. =item C<assigned_to> 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. @@ -1799,6 +2234,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. @@ -1815,14 +2255,16 @@ 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 C<YYYY-MM-DD>. -If you are not in the time-tracking group, this field will not be included -in the return value. - =item C<depends_on> C<array> of C<int>s. The ids of bugs that this bug "depends on". @@ -1908,7 +2350,7 @@ C<boolean> True if this bug is open, false if it is closed. =item C<is_creator_accessible> C<boolean> If true, this bug can be accessed by the creator (reporter) -of the bug, even if he or she is not a member of the groups the bug +of the bug, even if they are not a member of the groups the bug is restricted to. =item C<keywords> @@ -1939,6 +2381,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 @@ -2002,7 +2449,11 @@ C<string> The value of the "status whiteboard" field on the bug. Every custom field in this installation will also be included in the return value. Most fields are returned as C<string>s. However, some -field types have different return values: +field types have different return values. + +Normally custom fields are returned by default similar to normal bug +fields or you can specify only custom fields by using C<_custom> in +C<include_fields>. =over @@ -2014,6 +2465,42 @@ 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 + +These fields are returned only by specifying "_extra" or the field name in "include_fields". + +=over + +=item C<tags> + +C<array> of C<string>s. Each array item is a tag name. + +Note that tags are personal to the currently logged in user. + =back =item C<faults> B<EXPERIMENTAL> @@ -2065,7 +2552,7 @@ You do not have access to the bug_id you specified. =over -=item C<permissive> argument added to this method's params in Bugzilla B<3.4>. +=item C<permissive> argument added to this method's params in Bugzilla B<3.4>. =item The following properties were added to this method's return values in Bugzilla B<3.4>: @@ -2113,6 +2600,10 @@ and all custom fields. =item The C<actual_time> item was added to the C<bugs> return value in Bugzilla B<4.4>. +=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 @@ -2127,6 +2618,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 /rest/bug/<bug_id>/history + +The returned data format will be the same as below. + =item B<Params> =over @@ -2138,7 +2637,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. =back @@ -2155,7 +2659,8 @@ C<int> The numeric id of the bug. =item alias -C<string> The alias of this bug. If there is no alias, this will be undef. +C<array> of C<string>s The unique aliases of this bug. An empty array will be +returned if this bug has no aliases. =item history @@ -2218,6 +2723,10 @@ The same as L</get>. consistent with other methods. Since Bugzilla B<4.4>, they now match names used by L<Bug.update|/"update"> for consistency. +=item REST API call added Bugzilla B<5.0>. + +=item Added C<new_since> parameter if Bugzilla B<5.0>. + =back =back @@ -2241,7 +2750,7 @@ narrowed down to specific products. =item C<summary> (string) B<Required> - A string of keywords defining the type of bug you are trying to report. -=item C<product> (array) - One or more product names to narrow the +=item C<products> (array) - One or more product names to narrow the duplicate search to. If omitted, all bugs are searched. =back @@ -2272,6 +2781,9 @@ search for duplicates. =item Added in Bugzilla B<4.0>. +=item The C<product> parameter has been renamed to C<products> in +Bugzilla B<5.0>. + =back =back @@ -2286,6 +2798,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 @@ -2306,15 +2826,25 @@ 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> -C<string> The unique alias for this bug. +C<array> of C<string>s The unique aliases of this bug. An empty array will be +returned if this bug has no aliases. =item C<assigned_to> @@ -2403,6 +2933,13 @@ on spaces. So searching for C<foo bar> will match "This is a foo bar" but not "This foo is a bar". C<['foo', 'bar']>, would, however, match the second item. +=item C<tags> + +C<string> Searches for a bug with the specified tag. If you specify an +array, then any bugs that match I<any> of the tags will be returned. + +Note that tags are personal to the currently logged in user. + =item C<target_milestone> C<string> The Target Milestone field of a bug. Note that even if this @@ -2432,6 +2969,10 @@ 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<quicksearch> + +C<string> Search for bugs using quicksearch syntax. + =back =item B<Returns> @@ -2471,6 +3012,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 @@ -2497,10 +3045,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 /rest/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 @@ -2544,7 +3101,7 @@ in by the developer, compared to the developer's other bugs. =item C<severity> (string) B<Defaulted> - How severe the bug is. -=item C<alias> (string) - A brief alias for the bug that can be used +=item C<alias> (array) - A brief alias for the bug that can be used instead of a bug number when accessing this bug. Must be unique in all of this Bugzilla. @@ -2579,6 +3136,32 @@ with L</update>. =item C<target_milestone> (string) - A valid target milestone for this product. +=item C<flags> + +C<array> An array of hashes with flags to add to the bug. To create a flag, +at least the status and the type_id or name must be provided. An optional +requestee can be passed if the flag type is requestable to a specific user. + +=over + +=item C<name> + +C<string> The name of the flag type. + +=item C<type_id> + +C<int> The internal flag type id. + +=item C<status> + +C<string> The flags new status (i.e. "?", "+", "-" or "X" to clear a flag). + +=item C<requestee> + +C<string> The login of the requestee if the flag type is requestable to a specific user. + +=back + =back In addition to the above parameters, if your installation has any custom @@ -2631,6 +3214,28 @@ that would cause a circular dependency between bugs. You tried to restrict the bug to a group which does not exist, or which you cannot use with this product. +=item 129 (Flag Status Invalid) + +The flag status is invalid. + +=item 130 (Flag Modification Denied) + +You tried to request, grant, or deny a flag but only a user with the required +permissions may make the change. + +=item 131 (Flag not Requestable from Specific Person) + +You can't ask a specific person for the flag. + +=item 133 (Flag Type not Unique) + +The flag type specified matches several flag types. You must specify +the type id value to update or add a flag. + +=item 134 (Inactive Flag Type) + +The flag type is inactive and cannot be used to create new flags. + =item 504 (Invalid User) Either the QA Contact, Assignee, or CC lists have some invalid user @@ -2661,6 +3266,8 @@ loop errors had a generic code of C<32000>. =item The ability to file new bugs with a C<resolution> was added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -2676,6 +3283,16 @@ B<STABLE> This allows you to add an attachment to a bug in Bugzilla. +=item B<REST> + +To create attachment on a current bug: + +POST /rest/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 @@ -2727,6 +3344,32 @@ to the "insidergroup"), False if the attachment should be public. Defaults to False if not specified. +=item C<flags> + +C<array> An array of hashes with flags to add to the attachment. to create a flag, +at least the status and the type_id or name must be provided. An optional requestee +can be passed if the flag type is requestable to a specific user. + +=over + +=item C<name> + +C<string> The name of the flag type. + +=item C<type_id> + +C<int> The internal flag type id. + +=item C<status> + +C<string> The flags new status (i.e. "?", "+", "-" or "X" to clear a flag). + +=item C<requestee> + +C<string> The login of the requestee if the flag type is requestable to a specific user. + +=back + =back =item B<Returns> @@ -2740,6 +3383,28 @@ This method can throw all the same errors as L</get>, plus: =over +=item 129 (Flag Status Invalid) + +The flag status is invalid. + +=item 130 (Flag Modification Denied) + +You tried to request, grant, or deny a flag but only a user with the required +permissions may make the change. + +=item 131 (Flag not Requestable from Specific Person) + +You can't ask a specific person for the flag. + +=item 133 (Flag Type not Unique) + +The flag type specified matches several flag types. You must specify +the type id value to update or add a flag. + +=item 134 (Inactive Flag Type) + +The flag type is inactive and cannot be used to create new flags. + =item 600 (Attachment Too Large) You tried to attach a file that was larger than Bugzilla will accept. @@ -2773,10 +3438,229 @@ You set the "data" field to an empty string. =item The return value has changed in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + +=back + +=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 /rest/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<comment> + +C<string> An optional comment to add to the attachment's bug. + +=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. + +=item C<flags> + +C<array> An array of hashes with changes to the flags. The following values +can be specified. At least the status and one of type_id, id, or name must +be specified. If a type_id or name matches a single currently set flag, +the flag will be updated unless new is specified. + +=over + +=item C<name> + +C<string> The name of the flag that will be created or updated. + +=item C<type_id> + +C<int> The internal flag type id that will be created or updated. You will +need to specify the C<type_id> if more than one flag type of the same name exists. + +=item C<status> + +C<string> The flags new status (i.e. "?", "+", "-" or "X" to clear a flag). + +=item C<requestee> + +C<string> The login of the requestee if the flag type is requestable to a specific user. + +=item C<id> + +C<int> Use id to specify the flag to be updated. You will need to specify the C<id> +if more than one flag is set of the same name. + +=item C<new> + +C<boolean> Set to true if you specifically want a new flag to be created. + +=back + +=item B<Returns> + +A C<hash> with a single field, "attachments". 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 129 (Flag Status Invalid) + +The flag status is invalid. + +=item 130 (Flag Modification Denied) + +You tried to request, grant, or deny a flag but only a user with the required +permissions may make the change. + +=item 131 (Flag not Requestable from Specific Person) + +You can't ask a specific person for the flag. + +=item 132 (Flag not Unique) + +The flag specified has been set multiple times. You must specify the id +value to update the flag. + +=item 133 (Flag Type not Unique) + +The flag type specified matches several flag types. You must specify +the type id value to update or add a flag. + +=item 134 (Inactive Flag Type) + +The flag type is inactive and cannot be used to create new flags. + +=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 + +=back =head2 add_comment @@ -2788,6 +3672,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 /rest/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 @@ -2863,6 +3756,8 @@ 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>. + =back =back @@ -2879,6 +3774,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 /rest/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 @@ -2897,9 +3802,29 @@ bugs you are updating. =item C<alias> -(string) The alias of the bug. You can only set this if you are modifying -a single bug. If there is more than one bug specified in C<ids>, passing in -a value for C<alias> will cause an error to be thrown. +C<hash> These specify the aliases of a bug that can be used instead of a bug +number when acessing this bug. To set these, you should pass a hash as the +value. The hash may contain the following fields: + +=over + +=item C<add> An array of C<string>s. Aliases to add to this field. + +=item C<remove> An array of C<string>s. Aliases to remove from this field. +If the aliases are not already in the field, they will be ignored. + +=item C<set> An array of C<string>s. An exact set of aliases to set this +field to, overriding the current value. If you specify C<set>, then C<add> +and C<remove> will be ignored. + +=back + +You can only set this if you are modifying a single bug. If there is more +than one bug specified in C<ids>, passing in a value for C<alias> will cause +an error to be thrown. + +For backwards compatibility, you can also specify a single string. This will +be treated as if you specified the set key above. =item C<assigned_to> @@ -2998,6 +3923,43 @@ duplicate bugs. C<double> The total estimate of time required to fix the bug, in hours. This is the I<total> estimate, not the amount of time remaining to fix it. +=item C<flags> + +C<array> An array of hashes with changes to the flags. The following values +can be specified. At least the status and one of type_id, id, or name must +be specified. If a type_id or name matches a single currently set flag, +the flag will be updated unless new is specified. + +=over + +=item C<name> + +C<string> The name of the flag that will be created or updated. + +=item C<type_id> + +C<int> The internal flag type id that will be created or updated. You will +need to specify the C<type_id> if more than one flag type of the same name exists. + +=item C<status> + +C<string> The flags new status (i.e. "?", "+", "-" or "X" to clear a flag). + +=item C<requestee> + +C<string> The login of the requestee if the flag type is requestable to a specific user. + +=item C<id> + +C<int> Use id to specify the flag to be updated. You will need to specify the C<id> +if more than one flag is set of the same name. + +=item C<new> + +C<boolean> Set to true if you specifically want a new flag to be created. + +=back + =item C<groups> C<hash> The groups a bug is in. To modify this field, pass a hash, which @@ -3075,7 +4037,7 @@ C<string> The full login name of the bug's QA Contact. =item C<is_creator_accessible> C<boolean> Whether or not the bug's reporter is allowed to access -the bug, even if he or she isn't in a group that can normally access +the bug, even if they aren't in a group that can normally access the bug. =item C<remaining_time> @@ -3181,7 +4143,8 @@ C<int> The id of the bug that was updated. =item C<alias> -C<string> The alias of the bug that was updated, if this bug has an alias. +C<array> of C<string>s The aliases of the bug that was updated, if this bug +has any alias. =item C<last_change_time> @@ -3215,7 +4178,7 @@ Here's an example of what a return value might look like: bugs => [ { id => 123, - alias => 'foo', + alias => [ 'foo' ], last_change_time => '2010-01-01T12:34:56', changes => { status => { @@ -3315,6 +4278,33 @@ field. You tried to change from one status to another, but the status workflow rules don't allow that change. +=item 129 (Flag Status Invalid) + +The flag status is invalid. + +=item 130 (Flag Modification Denied) + +You tried to request, grant, or deny a flag but only a user with the required +permissions may make the change. + +=item 131 (Flag not Requestable from Specific Person) + +You can't ask a specific person for the flag. + +=item 132 (Flag not Unique) + +The flag specified has been set multiple times. You must specify the id +value to update the flag. + +=item 133 (Flag Type not Unique) + +The flag type specified matches several flag types. You must specify +the type id value to update or add a flag. + +=item 134 (Inactive Flag Type) + +The flag type is inactive and cannot be used to create new flags. + =back =item B<History> @@ -3323,6 +4313,8 @@ rules don't allow that change. =item Added in Bugzilla B<4.0>. +=item REST API call added Bugzilla B<5.0>. + =back =back @@ -3503,3 +4495,176 @@ This method can throw the same errors as L</get>. =back =back + +=head2 search_comment_tags + +B<UNSTABLE> + +=over + +=item B<Description> + +Searches for tags which contain the provided substring. + +=item B<REST> + +To search for comment tags: + +GET /rest/bug/comment/tags/<query> + +=item B<Params> + +=over + +=item C<query> + +B<Required> C<string> Only tags containg this substring will be returned. + +=item C<limit> + +C<int> If provided will return no more than C<limit> tags. Defaults to C<10>. + +=back + +=item B<Returns> + +An C<array of strings> of matching tags. + +=item B<Errors> + +This method can throw all of the errors that L</get> throws, plus: + +=over + +=item 125 (Comment Tagging Disabled) + +Comment tagging support is not available or enabled. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back + +=head2 update_comment_tags + +B<UNSTABLE> + +=over + +=item B<Description> + +Adds or removes tags from a comment. + +=item B<REST> + +To update the tags comments attached to a comment: + +PUT /rest/bug/comment/tags + +The params to include in the PUT body as well as the returned data format, +are the same as below. + +=item B<Params> + +=over + +=item C<comment_id> + +B<Required> C<int> The ID of the comment to update. + +=item C<add> + +C<array of strings> The tags to attach to the comment. + +=item C<remove> + +C<array of strings> The tags to detach from the comment. + +=back + +=item B<Returns> + +An C<array of strings> containing the comment's updated tags. + +=item B<Errors> + +This method can throw all of the errors that L</get> throws, plus: + +=over + +=item 125 (Comment Tagging Disabled) + +Comment tagging support is not available or enabled. + +=item 126 (Invalid Comment Tag) + +The comment tag provided was not valid (eg. contains invalid characters). + +=item 127 (Comment Tag Too Short) + +The comment tag provided is shorter than the minimum length. + +=item 128 (Comment Tag Too Long) + +The comment tag provided is longer than the maximum length. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back + +=head2 render_comment + +B<UNSTABLE> + +=over + +=item B<Description> + +Returns the HTML rendering of the provided comment text. + +=item B<Params> + +=over + +=item C<text> + +B<Required> C<strings> Text comment text to render. + +=item C<id> + +C<int> The ID of the bug to render the comment against. + +=back + +=item B<Returns> + +C<html> containing the HTML rendering. + +=item B<Errors> + +This method can throw all of the errors that L</get> throws. + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back diff --git a/Bugzilla/WebService/BugUserLastVisit.pm b/Bugzilla/WebService/BugUserLastVisit.pm new file mode 100644 index 000000000..19a56ff46 --- /dev/null +++ b/Bugzilla/WebService/BugUserLastVisit.pm @@ -0,0 +1,207 @@ +# 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::BugUserLastVisit; + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::WebService); + +use Bugzilla::Bug; +use Bugzilla::Error; +use Bugzilla::WebService::Util qw( validate filter ); +use Bugzilla::Constants; + +use constant PUBLIC_METHODS => qw( + get + update +); + +sub update { + my ($self, $params) = validate(@_, 'ids'); + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + + $user->login(LOGIN_REQUIRED); + + my $ids = $params->{ids} // []; + ThrowCodeError('param_required', { param => 'ids' }) unless @$ids; + + # 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. + $user->visible_bugs([grep /^[0-9]$/, @$ids]); + + $dbh->bz_start_transaction(); + my @results; + my $last_visit_ts = $dbh->selectrow_array('SELECT NOW()'); + foreach my $bug_id (@$ids) { + my $bug = Bugzilla::Bug->check({ id => $bug_id, cache => 1 }); + + ThrowUserError('user_not_involved', { bug_id => $bug->id }) + unless $user->is_involved_in_bug($bug); + + $bug->update_user_last_visit($user, $last_visit_ts); + + push( + @results, + $self->_bug_user_last_visit_to_hash( + $bug, $last_visit_ts, $params + )); + } + $dbh->bz_commit_transaction(); + + return \@results; +} + +sub get { + my ($self, $params) = validate(@_, 'ids'); + my $user = Bugzilla->user; + my $ids = $params->{ids}; + + $user->login(LOGIN_REQUIRED); + + if ($ids) { + # 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. + $user->visible_bugs([grep /^[0-9]$/, @$ids]); + } + + my @last_visits = @{ $user->last_visited }; + + if ($ids) { + # remove bugs that we are not interested in if ids is passed in. + my %id_set = map { ($_ => 1) } @$ids; + @last_visits = grep { $id_set{ $_->bug_id } } @last_visits; + } + + return [ + map { + $self->_bug_user_last_visit_to_hash($_->bug_id, $_->last_visit_ts, + $params) + } @last_visits + ]; +} + +sub _bug_user_last_visit_to_hash { + my ($self, $bug_id, $last_visit_ts, $params) = @_; + + my %result = (id => $self->type('int', $bug_id), + last_visit_ts => $self->type('dateTime', $last_visit_ts)); + + return filter($params, \%result); +} + +1; + +__END__ +=head1 NAME + +Bugzilla::WebService::BugUserLastVisit - Find and Store the last time a user +visited a bug. + +=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. + +=head2 update + +B<EXPERIMENTAL> + +=over + +=item B<Description> + +Update the last visit time for the specified bug and current user. + +=item B<REST> + +To add a single bug id: + + POST /rest/bug_user_last_visit/<bug-id> + +Tp add one or more bug ids at once: + + POST /rest/bug_user_last_visit + +The returned data format is the same as below. + +=item B<Params> + +=over + +=item C<ids> (array) - One or more bug ids to add. + +=back + +=item B<Returns> + +=over + +=item C<array> - An array of hashes containing the following: + +=over + +=item C<id> - (int) The bug id. + +=item C<last_visit_ts> - (string) The timestamp the user last visited the bug. + +=back + +=back + +=back + +=head2 get + +B<EXPERIMENTAL> + +=over + +=item B<Description> + +Get the last visited timestamp for one or more specified bug ids. + +=item B<REST> + +To return the last visited timestamp for a single bug id: + + GET /rest/bug_user_last_visit/<bug-id> + +=item B<Params> + +=over + +=item C<ids> (integer) - One or more optional bug ids to get. + +=back + +=item B<Returns> + +=over + +=item C<array> - An array of hashes containing the following: + +=over + +=item C<id> - (int) The bug id. + +=item C<last_visit_ts> - (string) The timestamp the user last visited the bug. + +=back + +=back + +=back diff --git a/Bugzilla/WebService/Bugzilla.pm b/Bugzilla/WebService/Bugzilla.pm index a6037e67e..848cffd30 100644 --- a/Bugzilla/WebService/Bugzilla.pm +++ b/Bugzilla/WebService/Bugzilla.pm @@ -7,8 +7,11 @@ package Bugzilla::WebService::Bugzilla; +use 5.10.1; use strict; -use base qw(Bugzilla::WebService); +use warnings; + +use parent qw(Bugzilla::WebService); use Bugzilla::Constants; use Bugzilla::Util qw(datetime_from); use Bugzilla::WebService::Util qw(validate filter_wants); @@ -128,12 +131,12 @@ sub time { sub last_audit_time { my ($self, $params) = validate(@_, 'class'); my $dbh = Bugzilla->dbh; - + my $sql_statement = "SELECT MAX(at_time) FROM audit_log"; my $class_values = $params->{class}; my @class_values_quoted; foreach my $class_value (@$class_values) { - push (@class_values_quoted, $dbh->quote($class_value)) + push (@class_values_quoted, $dbh->quote($class_value)) if $class_value =~ /^Bugzilla(::[a-zA-Z0-9_]+)*$/; } @@ -142,11 +145,11 @@ sub last_audit_time { } my $last_audit_time = $dbh->selectrow_array("$sql_statement"); - + # All Webservices return times in UTC; Use UTC here for backwards compat. # Hardcode values where appropriate $last_audit_time = datetime_from($last_audit_time, 'UTC'); - + return { last_audit_time => $self->type('dateTime', $last_audit_time) }; @@ -154,7 +157,7 @@ sub last_audit_time { sub parameters { my ($self, $args) = @_; - my $user = Bugzilla->login(); + my $user = Bugzilla->login(LOGIN_OPTIONAL); my $params = Bugzilla->params; $args ||= {}; @@ -188,6 +191,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> @@ -198,6 +205,12 @@ B<STABLE> Returns the current version of Bugzilla. +=item B<REST> + +GET /rest/version + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -207,6 +220,14 @@ string. =item B<Errors> (none) +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head2 extensions @@ -220,6 +241,12 @@ B<EXPERIMENTAL> Gets information about the extensions that are currently installed and enabled in this Bugzilla. +=item B<REST> + +GET /rest/extensions + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -250,6 +277,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 @@ -265,6 +294,12 @@ Use L</time> instead. Returns the timezone that Bugzilla expects dates and times in. +=item B<REST> + +GET /rest/timezone + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -279,6 +314,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 @@ -295,6 +332,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 /rest/time + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -305,7 +348,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 @@ -315,7 +358,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 @@ -331,7 +374,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> @@ -355,6 +398,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 @@ -369,6 +414,12 @@ B<UNSTABLE> Returns parameter values currently used in this Bugzilla. +=item B<REST> + +GET /rest/parameters + +The returned data format is the same as below. + =item B<Params> (none) =item B<Returns> @@ -426,6 +477,8 @@ never be stable. =item Added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -440,9 +493,15 @@ B<EXPERIMENTAL> Gets the latest time of the audit_log table. +=item B<REST> + +GET /rest/last_audit_time + +The returned data format is the same as below. + =item B<Params> -You can pass the optional parameter C<class> to get the maximum for only +You can pass the optional parameter C<class> to get the maximum for only the listed classes. =over @@ -467,6 +526,8 @@ at_time from the audit_log. =item Added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + =back =back diff --git a/Bugzilla/WebService/Classification.pm b/Bugzilla/WebService/Classification.pm index f2c3ec51e..cee597b68 100644 --- a/Bugzilla/WebService/Classification.pm +++ b/Bugzilla/WebService/Classification.pm @@ -7,9 +7,11 @@ package Bugzilla::WebService::Classification; +use 5.10.1; use strict; +use warnings; -use base qw (Bugzilla::WebService); +use parent qw (Bugzilla::WebService); use Bugzilla::Classification; use Bugzilla::Error; @@ -44,39 +46,37 @@ sub get { @classification_objs = grep { $selectable_class{$_->id} } @classification_objs; } - my @classifications = map { filter($params, $self->_classification_to_hash($_)) } @classification_objs; + my @classifications = map { $self->_classification_to_hash($_, $params) } @classification_objs; return { classifications => \@classifications }; } sub _classification_to_hash { - my ($self, $classification) = @_; + my ($self, $classification, $params) = @_; 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 = ( + + return filter $params, { 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; + products => [ map { $self->_product_to_hash($_, $params) } @$products ], + }; } sub _product_to_hash { - my ($self, $product) = @_; - my %hash = ( + my ($self, $product, $params) = @_; + + return filter $params, { id => $self->type('int', $product->id), name => $self->type('string', $product->name), description => $self->type('string', $product->description), - ); - - return \%hash; + }, undef, 'products'; } 1; @@ -89,7 +89,7 @@ Bugzilla::Webservice::Classification - The Classification API =head1 DESCRIPTION -This part of the Bugzilla API allows you to deal with the available Classifications. +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 @@ -97,6 +97,10 @@ You will be able to get information about them as well as manipulate 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 Classification Retrieval =head2 get @@ -109,13 +113,21 @@ B<EXPERIMENTAL> Returns a hash containing information about a set of classifications. +=item B<REST> + +To return information on a single classification: + +GET /rest/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. +You could get classifications info by supplying their names and/or ids. So, this method accepts the following parameters: =over @@ -130,10 +142,10 @@ An array of classification names. =back -=item B<Returns> +=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 +Each element of the array represents a classification that the user is authorized to see and has the following keys: =over @@ -193,6 +205,8 @@ Classification is not enabled on this installation. =item Added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + =back =back diff --git a/Bugzilla/WebService/Component.pm b/Bugzilla/WebService/Component.pm new file mode 100644 index 000000000..4d6723d8b --- /dev/null +++ b/Bugzilla/WebService/Component.pm @@ -0,0 +1,153 @@ +# 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::Component; + +use 5.10.1; +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Util qw(translate params_to_objects validate); + +use constant PUBLIC_METHODS => qw( + create +); + +use constant MAPPED_FIELDS => { + default_assignee => 'initialowner', + default_qa_contact => 'initialqacontact', + default_cc => 'initial_cc', + is_open => 'isactive', +}; + +sub create { + my ($self, $params) = @_; + + my $user = Bugzilla->login(LOGIN_REQUIRED); + + $user->in_group('editcomponents') + || scalar @{ $user->get_products_by_permission('editcomponents') } + || ThrowUserError('auth_failure', { group => 'editcomponents', + action => 'edit', + object => 'components' }); + + my $product = $user->check_can_admin_product($params->{product}); + + # Translate the fields + my $values = translate($params, MAPPED_FIELDS); + $values->{product} = $product; + + # Create the component and return the newly created id. + my $component = Bugzilla::Component->create($values); + return { id => $self->type('int', $component->id) }; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Component - The Component API + +=head1 DESCRIPTION + +This part of the Bugzilla API allows you to deal with the available product components. +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. + +=head1 Component Creation and Modification + +=head2 create + +B<EXPERIMENTAL> + +=over + +=item B<Description> + +This allows you to create a new component in Bugzilla. + +=item B<Params> + +Some params must be set, or an error will be thrown. These params are +marked B<Required>. + +=over + +=item C<name> + +B<Required> C<string> The name of the new component. + +=item C<product> + +B<Required> C<string> The name of the product that the component must be +added to. This product must already exist, and the user have the necessary +permissions to edit components for it. + +=item C<description> + +B<Required> C<string> The description of the new component. + +=item C<default_assignee> + +B<Required> C<string> The login name of the default assignee of the component. + +=item C<default_cc> + +C<array> An array of strings with each element representing one login name of the default CC list. + +=item C<default_qa_contact> + +C<string> The login name of the default QA contact for the component. + +=item C<is_open> + +C<boolean> 1 if you want to enable the component for bug creations. 0 otherwise. Default is 1. + +=back + +=item B<Returns> + +A hash with one key: C<id>. This will represent the ID of the newly-added +component. + +=item B<Errors> + +=over + +=item 304 (Authorization Failure) + +You are not authorized to create a new component. + +=item 1200 (Component already exists) + +The name that you specified for the new component already exists in the +specified product. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back + diff --git a/Bugzilla/WebService/Constants.pm b/Bugzilla/WebService/Constants.pm index f289caef4..0bdd3517e 100644 --- a/Bugzilla/WebService/Constants.pm +++ b/Bugzilla/WebService/Constants.pm @@ -7,14 +7,30 @@ package Bugzilla::WebService::Constants; +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use parent 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 ); @@ -66,8 +82,9 @@ use constant WS_ERROR_CODE => { illegal_field => 104, freetext_too_long => 104, # Component errors - require_component => 105, - component_name_too_long => 105, + require_component => 105, + component_name_too_long => 105, + product_unknown_component => 105, # Invalid Product no_products => 106, entry_access_denied => 106, @@ -83,7 +100,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, @@ -106,14 +128,25 @@ use constant WS_ERROR_CODE => { missing_resolution => 121, resolution_not_allowed => 122, illegal_bug_status_transition => 123, + # Flag errors + flag_status_invalid => 129, + flag_update_denied => 130, + flag_type_requestee_disabled => 131, + flag_not_unique => 132, + flag_type_not_unique => 133, + flag_type_inactive => 134, # Authentication errors are usually 300-400. - invalid_username_or_password => 300, + invalid_login_or_password => 300, account_disabled => 301, auth_invalid_email => 302, extern_id_conflict => -303, auth_failure => 304, - password_current_too_short => 305, + password_too_short => 305, + password_not_complex => 305, + api_key_not_valid => 306, + api_key_revoked => 306, + auth_invalid_token => 307, # Except, historically, AUTH_NODATA, which is 410. login_required => 410, @@ -157,6 +190,7 @@ use constant WS_ERROR_CODE => { empty_group_description => 802, invalid_regexp => 803, invalid_group_name => 804, + group_cannot_view => 805, # Classification errors are 900-1000 auth_classification_not_enabled => 900, @@ -164,14 +198,73 @@ use constant WS_ERROR_CODE => { # Search errors are 1000-1100 buglist_parameters_required => 1000, + # Flag type errors are 1100-1200 + flag_type_name_invalid => 1101, + flag_type_description_invalid => 1102, + flag_type_cc_list_invalid => 1103, + flag_type_sortkey_invalid => 1104, + flag_type_not_editable => 1105, + + # Component errors are 1200-1300 + component_already_exists => 1200, + component_is_last => 1201, + component_has_bugs => 1202, + # Errors thrown by the WebService itself. The ones that are negative # conform to http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php xmlrpc_invalid_value => -32600, 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. +sub REST_STATUS_CODE_MAP { + my $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, + 32614 => STATUS_NOT_FOUND, + _default => STATUS_BAD_REQUEST + }; + + Bugzilla::Hook::process('webservice_status_code_map', + { status_code_map => $status_code_map }); + + return $status_code_map; }; # These are the fallback defaults for errors not in ERROR_CODE. @@ -185,6 +278,14 @@ use constant XMLRPC_CONTENT_TYPE_WHITELIST => qw( application/xml ); +# The first content type specified is used as the default. +use constant REST_CONTENT_TYPE_WHITELIST => qw( + application/json + application/javascript + text/javascript + text/html +); + sub WS_DISPATCH { # We "require" here instead of "use" above to avoid a dependency loop. require Bugzilla::Hook; @@ -192,15 +293,28 @@ sub WS_DISPATCH { Bugzilla::Hook::process('webservice', { dispatch => \%hook_dispatch }); my $dispatch = { - 'Bugzilla' => 'Bugzilla::WebService::Bugzilla', - 'Bug' => 'Bugzilla::WebService::Bug', - 'Classification' => 'Bugzilla::WebService::Classification', - 'Group' => 'Bugzilla::WebService::Group', - 'Product' => 'Bugzilla::WebService::Product', - 'User' => 'Bugzilla::WebService::User', + 'Bugzilla' => 'Bugzilla::WebService::Bugzilla', + 'Bug' => 'Bugzilla::WebService::Bug', + 'Classification' => 'Bugzilla::WebService::Classification', + 'Component' => 'Bugzilla::WebService::Component', + 'FlagType' => 'Bugzilla::WebService::FlagType', + 'Group' => 'Bugzilla::WebService::Group', + 'Product' => 'Bugzilla::WebService::Product', + 'User' => 'Bugzilla::WebService::User', + 'BugUserLastVisit' => 'Bugzilla::WebService::BugUserLastVisit', %hook_dispatch }; return $dispatch; }; 1; + +=head1 B<Methods in need of POD> + +=over + +=item REST_STATUS_CODE_MAP + +=item WS_DISPATCH + +=back diff --git a/Bugzilla/WebService/FlagType.pm b/Bugzilla/WebService/FlagType.pm new file mode 100644 index 000000000..9723d4735 --- /dev/null +++ b/Bugzilla/WebService/FlagType.pm @@ -0,0 +1,834 @@ +# 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::FlagType; + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::WebService); +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::FlagType; +use Bugzilla::Product; +use Bugzilla::Util qw(trim); + +use List::MoreUtils qw(uniq); + +use constant PUBLIC_METHODS => qw( + create + get + update +); + +sub get { + my ($self, $params) = @_; + my $dbh = Bugzilla->switch_to_shadow_db(); + my $user = Bugzilla->user; + + defined $params->{product} + || ThrowCodeError('param_required', + { function => 'Bug.flag_types', + param => 'product' }); + + my $product = delete $params->{product}; + my $component = delete $params->{component}; + + $product = Bugzilla::Product->check({ name => $product, cache => 1 }); + $component = Bugzilla::Component->check( + { name => $component, product => $product, cache => 1 }) if $component; + + my $flag_params = { product_id => $product->id }; + $flag_params->{component_id} = $component->id if $component; + my $matched_flag_types = Bugzilla::FlagType::match($flag_params); + + my $flag_types = { bug => [], attachment => [] }; + foreach my $flag_type (@$matched_flag_types) { + push(@{ $flag_types->{bug} }, $self->_flagtype_to_hash($flag_type, $product)) + if $flag_type->target_type eq 'bug'; + push(@{ $flag_types->{attachment} }, $self->_flagtype_to_hash($flag_type, $product)) + if $flag_type->target_type eq 'attachment'; + } + + return $flag_types; +} + +sub create { + my ($self, $params) = @_; + + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + + Bugzilla->user->in_group('editcomponents') + || scalar(@{$user->get_products_by_permission('editcomponents')}) + || ThrowUserError("auth_failure", { group => "editcomponents", + action => "add", + object => "flagtypes" }); + + $params->{name} || ThrowCodeError('param_required', { param => 'name' }); + $params->{description} || ThrowCodeError('param_required', { param => 'description' }); + + my %args = ( + sortkey => 1, + name => undef, + inclusions => ['0:0'], # Default to __ALL__:__ALL__ + cc_list => '', + description => undef, + is_requestable => 'on', + exclusions => [], + is_multiplicable => 'on', + request_group => '', + is_active => 'on', + is_specifically_requestable => 'on', + target_type => 'bug', + grant_group => '', + ); + + foreach my $key (keys %args) { + $args{$key} = $params->{$key} if defined($params->{$key}); + } + + $args{name} = trim($params->{name}); + $args{description} = trim($params->{description}); + + # Is specifically requestable is actually is_requesteeable + if (exists $args{is_specifically_requestable}) { + $args{is_requesteeble} = delete $args{is_specifically_requestable}; + } + + # Default is on for the tickbox flags. + # If the user has set them to 'off' then undefine them so the flags are not ticked + foreach my $arg_name (qw(is_requestable is_multiplicable is_active is_requesteeble)) { + if (defined($args{$arg_name}) && ($args{$arg_name} eq '0')) { + $args{$arg_name} = undef; + } + } + + # Process group inclusions and exclusions + $args{inclusions} = _process_lists($params->{inclusions}) if defined $params->{inclusions}; + $args{exclusions} = _process_lists($params->{exclusions}) if defined $params->{exclusions}; + + my $flagtype = Bugzilla::FlagType->create(\%args); + + return { id => $self->type('int', $flagtype->id) }; +} + +sub update { + my ($self, $params) = @_; + + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + + Bugzilla->login(LOGIN_REQUIRED); + $user->in_group('editcomponents') + || scalar(@{$user->get_products_by_permission('editcomponents')}) + || ThrowUserError("auth_failure", { group => "editcomponents", + action => "edit", + object => "flagtypes" }); + + defined($params->{names}) || defined($params->{ids}) + || ThrowCodeError('params_required', + { function => 'FlagType.update', params => ['ids', 'names'] }); + + # Get the list of unique flag type ids we are updating + my @flag_type_ids = defined($params->{ids}) ? @{$params->{ids}} : (); + if (defined $params->{names}) { + push @flag_type_ids, map { $_->id } + @{ Bugzilla::FlagType::match({ name => $params->{names} }) }; + } + @flag_type_ids = uniq @flag_type_ids; + + # We delete names and ids to keep only new values to set. + delete $params->{names}; + delete $params->{ids}; + + # Process group inclusions and exclusions + # We removed them from $params because these are handled differently + my $inclusions = _process_lists(delete $params->{inclusions}) if defined $params->{inclusions}; + my $exclusions = _process_lists(delete $params->{exclusions}) if defined $params->{exclusions}; + + $dbh->bz_start_transaction(); + my %changes = (); + + foreach my $flag_type_id (@flag_type_ids) { + my ($flagtype, $can_fully_edit) = $user->check_can_admin_flagtype($flag_type_id); + + if ($can_fully_edit) { + $flagtype->set_all($params); + } + elsif (scalar keys %$params) { + ThrowUserError('flag_type_not_editable', { flagtype => $flagtype }); + } + + # Process the clusions + foreach my $type ('inclusions', 'exclusions') { + my $clusions = $type eq 'inclusions' ? $inclusions : $exclusions; + next if not defined $clusions; + + my @extra_clusions = (); + if (!$user->in_group('editcomponents')) { + my $products = $user->get_products_by_permission('editcomponents'); + # Bring back the products the user cannot edit. + foreach my $item (values %{$flagtype->$type}) { + my ($prod_id, $comp_id) = split(':', $item); + push(@extra_clusions, $item) unless grep { $_->id == $prod_id } @$products; + } + } + + $flagtype->set_clusions({ + $type => [@$clusions, @extra_clusions], + }); + } + + my $returned_changes = $flagtype->update(); + $changes{$flagtype->id} = { + name => $flagtype->name, + changes => $returned_changes, + }; + } + $dbh->bz_commit_transaction(); + + my @result; + foreach my $flag_type_id (keys %changes) { + my %hash = ( + id => $self->type('int', $flag_type_id), + name => $self->type('string', $changes{$flag_type_id}{name}), + changes => {}, + ); + + foreach my $field (keys %{ $changes{$flag_type_id}{changes} }) { + my $change = $changes{$flag_type_id}{changes}{$field}; + $hash{changes}{$field} = { + removed => $self->type('string', $change->[0]), + added => $self->type('string', $change->[1]) + }; + } + + push(@result, \%hash); + } + + return { flagtypes => \@result }; +} + +sub _flagtype_to_hash { + my ($self, $flagtype, $product) = @_; + my $user = Bugzilla->user; + + my @values = ('X'); + push(@values, '?') if ($flagtype->is_requestable && $user->can_request_flag($flagtype)); + push(@values, '+', '-') if $user->can_set_flag($flagtype); + + my $item = { + id => $self->type('int' , $flagtype->id), + name => $self->type('string' , $flagtype->name), + description => $self->type('string' , $flagtype->description), + type => $self->type('string' , $flagtype->target_type), + values => \@values, + is_active => $self->type('boolean', $flagtype->is_active), + is_requesteeble => $self->type('boolean', $flagtype->is_requesteeble), + is_multiplicable => $self->type('boolean', $flagtype->is_multiplicable) + }; + + if ($product) { + my $inclusions = $self->_flagtype_clusions_to_hash($flagtype->inclusions, $product->id); + my $exclusions = $self->_flagtype_clusions_to_hash($flagtype->exclusions, $product->id); + # if we have both inclusions and exclusions, the exclusions are redundant + $exclusions = [] if @$inclusions && @$exclusions; + # no need to return anything if there's just "any component" + $item->{inclusions} = $inclusions if @$inclusions && $inclusions->[0] ne ''; + $item->{exclusions} = $exclusions if @$exclusions && $exclusions->[0] ne ''; + } + + return $item; +} + +sub _flagtype_clusions_to_hash { + my ($self, $clusions, $product_id) = @_; + my $result = []; + foreach my $key (keys %$clusions) { + my ($prod_id, $comp_id) = split(/:/, $clusions->{$key}, 2); + if ($prod_id == 0 || $prod_id == $product_id) { + if ($comp_id) { + my $component = Bugzilla::Component->new({ id => $comp_id, cache => 1 }); + push @$result, $component->name; + } + else { + return [ '' ]; + } + } + } + return $result; +} + +sub _process_lists { + my $list = shift; + my $user = Bugzilla->user; + + my @products; + if ($user->in_group('editcomponents')) { + @products = Bugzilla::Product->get_all; + } + else { + @products = @{$user->get_products_by_permission('editcomponents')}; + } + + my @component_list; + + foreach my $item (@$list) { + # A hash with products as the key and component names as the values + if(ref($item) eq 'HASH') { + while (my ($product_name, $component_names) = each %$item) { + my $product = Bugzilla::Product->check({name => $product_name}); + unless (grep { $product->name eq $_->name } @products) { + ThrowUserError('product_access_denied', { name => $product_name }); + } + my @component_ids; + + foreach my $comp_name (@$component_names) { + my $component = Bugzilla::Component->check({product => $product, name => $comp_name}); + ThrowCodeError('param_invalid', { param => $comp_name}) unless defined $component; + push @component_list, $product->id . ':' . $component->id; + } + } + } + elsif(!ref($item)) { + # These are whole products + my $product = Bugzilla::Product->check({name => $item}); + unless (grep { $product->name eq $_->name } @products) { + ThrowUserError('product_access_denied', { name => $item }); + } + push @component_list, $product->id . ':0'; + } + else { + # The user has passed something invalid + ThrowCodeError('param_invalid', { param => $item }); + } + } + + return \@component_list; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::WebService::FlagType - API for creating flags. + +=head1 DESCRIPTION + +This part of the Bugzilla API allows you to create new flags + +=head1 METHODS + +See L<Bugzilla::WebService> for a description of what B<STABLE>, B<UNSTABLE>, +and B<EXPERIMENTAL> mean, and for more description about error codes. + +=head2 Get Flag Types + +=over + +=item C<get> B<UNSTABLE> + +=item B<Description> + +Get information about valid flag types that can be set for bugs and attachments. + +=item B<REST> + +You have several options for retreiving information about flag types. The first +part is the request method and the rest is the related path needed. + +To get information about all flag types for a product: + +GET /rest/flag_type/<product> + +To get information about flag_types for a product and component: + +GET /rest/flag_type/<product>/<component> + +The returned data format is the same as below. + +=item B<Params> + +You must pass a product name and an optional component name. + +=over + +=item C<product> (string) - The name of a valid product. + +=item C<component> (string) - An optional valid component name associated with the product. + +=back + +=item B<Returns> + +A hash containing two keys, C<bug> and C<attachment>. Each key value is an array of hashes, +containing the following keys: + +=over + +=item C<id> + +C<int> An integer id uniquely identifying this flag type. + +=item C<name> + +C<string> The name for the flag type. + +=item C<type> + +C<string> The target of the flag type which is either C<bug> or C<attachment>. + +=item C<description> + +C<string> The description of the flag type. + +=item C<values> + +C<array> An array of string values that the user can set on the flag type. + +=item C<is_requesteeble> + +C<boolean> Users can ask specific other users to set flags of this type. + +=item C<is_multiplicable> + +C<boolean> Multiple flags of this type can be set for the same bug or attachment. + +=back + +=item B<Errors> + +=over + +=item 106 (Product Access Denied) + +Either the product does not exist or you don't have access to it. + +=item 51 (Invalid Component) + +The component provided does not exist in the product. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back + +=head2 Create Flag + +=over + +=item C<create> B<UNSTABLE> + +=item B<Description> + +Creates a new FlagType + +=item B<REST> + +POST /rest/flag_type + +The params to include in the POST body as well as the returned data format, +are the same as below. + +=item B<Params> + +At a minimum the following two arguments must be supplied: + +=over + +=item C<name> (string) - The name of the new Flag Type. + +=item C<description> (string) - A description for the Flag Type object. + +=back + +=item B<Returns> + +C<int> flag_id + +The ID of the new FlagType object is returned. + +=item B<Params> + +=over + +=item name B<required> + +C<string> A short name identifying this type. + +=item description B<required> + +C<string> A comprehensive description of this type. + +=item inclusions B<optional> + +An array of strings or a hash containing product names, and optionally +component names. If you provide a string, the flag type will be shown on +all bugs in that product. If you provide a hash, the key represents the +product name, and the value is the components of the product to be included. + +For example: + + [ 'FooProduct', + { + BarProduct => [ 'C1', 'C3' ], + BazProduct => [ 'C7' ] + } + ] + +This flag will be added to B<All> components of I<FooProduct>, +components C1 and C3 of I<BarProduct>, and C7 of I<BazProduct>. + +=item exclusions B<optional> + +An array of strings or hashes containing product names. This uses the same +fromat as inclusions. + +This will exclude the flag from all products and components specified. + +=item sortkey B<optional> + +C<int> A number between 1 and 32767 by which this type will be sorted when +displayed to users in a list; ignore if you don't care what order the types +appear in or if you want them to appear in alphabetical order. + +=item is_active B<optional> + +C<boolean> Flag of this type appear in the UI and can be set. Default is B<true>. + +=item is_requestable B<optional> + +C<boolean> Users can ask for flags of this type to be set. Default is B<true>. + +=item cc_list B<optional> + +C<array> An array of strings. If the flag type is requestable, who should +receive e-mail notification of requests. This is an array of e-mail addresses +which do not need to be Bugzilla logins. + +=item is_specifically_requestable B<optional> + +C<boolean> Users can ask specific other users to set flags of this type as +opposed to just asking the wind. Default is B<true>. + +=item is_multiplicable B<optional> + +C<boolean> Multiple flags of this type can be set on the same bug. Default is B<true>. + +=item grant_group B<optional> + +C<string> The group allowed to grant/deny flags of this type (to allow all +users to grant/deny these flags, select no group). Default is B<no group>. + +=item request_group B<optional> + +C<string> If flags of this type are requestable, the group allowed to request +them (to allow all users to request these flags, select no group). Note that +the request group alone has no effect if the grant group is not defined! +Default is B<no group>. + +=back + +=item B<Errors> + +=over + +=item 51 (Group Does Not Exist) + +The group name you entered does not exist, or you do not have access to it. + +=item 105 (Unknown component) + +The component does not exist for this product. + +=item 106 (Product Access Denied) + +Either the product does not exist or you don't have editcomponents privileges +to it. + +=item 501 (Illegal Email Address) + +One of the e-mail address in the CC list is invalid. An e-mail in the CC +list does NOT need to be a valid Bugzilla user. + +=item 1101 (Flag Type Name invalid) + +You must specify a non-blank name for this flag type. It must +no contain spaces or commas, and must be 50 characters or less. + +=item 1102 (Flag type must have description) + +You must specify a description for this flag type. + +=item 1103 (Flag type CC list is invalid + +The CC list must be 200 characters or less. + +=item 1104 (Flag Type Sort Key Not Valid) + +The sort key is not a valid number. + +=item 1105 (Flag Type Not Editable) + +This flag type is not available for the products you can administer. Therefore +you can not edit attributes of the flag type, other than the inclusion and +exclusion list. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back + +=head2 update + +B<EXPERIMENTAL> + +=over + +=item B<Description> + +This allows you to update a flag type in Bugzilla. + +=item B<REST> + +PUT /rest/flag_type/<product_id_or_name> + +The params to include in the PUT body as well as the returned data format, +are the same as below. The C<ids> and C<names> params will be overridden as +it is pulled from the URL path. + +=item B<Params> + +B<Note:> The following parameters specify which products you are updating. +You must set one or both of these parameters. + +=over + +=item C<ids> + +C<array> of C<int>s. Numeric ids of the flag types that you wish to update. + +=item C<names> + +C<array> of C<string>s. Names of the flag types that you wish to update. If +many flag types have the same name, this will change ALL of them. + +=back + +B<Note:> The following parameters specify the new values you want to set for +the products you are updating. + +=over + +=item name + +C<string> A short name identifying this type. + +=item description + +C<string> A comprehensive description of this type. + +=item inclusions B<optional> + +An array of strings or a hash containing product names, and optionally +component names. If you provide a string, the flag type will be shown on +all bugs in that product. If you provide a hash, the key represents the +product name, and the value is the components of the product to be included. + +for example + + [ 'FooProduct', + { + BarProduct => [ 'C1', 'C3' ], + BazProduct => [ 'C7' ] + } + ] + +This flag will be added to B<All> components of I<FooProduct>, +components C1 and C3 of I<BarProduct>, and C7 of I<BazProduct>. + +=item exclusions B<optional> + +An array of strings or hashes containing product names. +This uses the same fromat as inclusions. + +This will exclude the flag from all products and components specified. + +=item sortkey + +C<int> A number between 1 and 32767 by which this type will be sorted when +displayed to users in a list; ignore if you don't care what order the types +appear in or if you want them to appear in alphabetical order. + +=item is_active + +C<boolean> Flag of this type appear in the UI and can be set. + +=item is_requestable + +C<boolean> Users can ask for flags of this type to be set. + +=item cc_list + +C<array> An array of strings. If the flag type is requestable, who should +receive e-mail notification of requests. This is an array of e-mail addresses +which do not need to be Bugzilla logins. + +=item is_specifically_requestable + +C<boolean> Users can ask specific other users to set flags of this type as +opposed to just asking the wind. + +=item is_multiplicable + +C<boolean> Multiple flags of this type can be set on the same bug. + +=item grant_group + +C<string> The group allowed to grant/deny flags of this type (to allow all +users to grant/deny these flags, select no group). + +=item request_group + +C<string> If flags of this type are requestable, the group allowed to request +them (to allow all users to request these flags, select no group). Note that +the request group alone has no effect if the grant group is not defined! + +=back + +=item B<Returns> + +A C<hash> with a single field "flagtypes". This points to an array of hashes +with the following fields: + +=over + +=item C<id> + +C<int> The id of the product that was updated. + +=item C<name> + +C<string> The name of the product that was updated. + +=item C<changes> + +C<hash> The changes that were actually done on this product. 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 value that this field was changed to. + +=item C<removed> + +C<string> The value that was previously set in this field. + +=back + +Note that booleans will be represented with the strings '1' and '0'. + +Here's an example of what a return value might look like: + + { + products => [ + { + id => 123, + changes => { + name => { + removed => 'FooFlagType', + added => 'BarFlagType' + }, + is_requestable => { + removed => '1', + added => '0', + } + } + } + ] + } + +=back + +=item B<Errors> + +=over + +=item 51 (Group Does Not Exist) + +The group name you entered does not exist, or you do not have access to it. + +=item 105 (Unknown component) + +The component does not exist for this product. + +=item 106 (Product Access Denied) + +Either the product does not exist or you don't have editcomponents privileges +to it. + +=item 501 (Illegal Email Address) + +One of the e-mail address in the CC list is invalid. An e-mail in the CC +list does NOT need to be a valid Bugzilla user. + +=item 1101 (Flag Type Name invalid) + +You must specify a non-blank name for this flag type. It must +no contain spaces or commas, and must be 50 characters or less. + +=item 1102 (Flag type must have description) + +You must specify a description for this flag type. + +=item 1103 (Flag type CC list is invalid + +The CC list must be 200 characters or less. + +=item 1104 (Flag Type Sort Key Not Valid) + +The sort key is not a valid number. + +=item 1105 (Flag Type Not Editable) + +This flag type is not available for the products you can administer. Therefore +you can not edit attributes of the flag type, other than the inclusion and +exclusion list. + +=back + +=item B<History> + +=over + +=item Added in Bugzilla B<5.0>. + +=back + +=back diff --git a/Bugzilla/WebService/Group.pm b/Bugzilla/WebService/Group.pm index 72c948aa4..468575a35 100644 --- a/Bugzilla/WebService/Group.pm +++ b/Bugzilla/WebService/Group.pm @@ -7,14 +7,18 @@ package Bugzilla::WebService::Group; +use 5.10.1; use strict; -use base qw(Bugzilla::WebService); +use warnings; + +use parent qw(Bugzilla::WebService); use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::WebService::Util qw(validate translate params_to_objects); use constant PUBLIC_METHODS => qw( create + get update ); @@ -97,6 +101,125 @@ sub update { return { groups => \@result }; } +sub get { + my ($self, $params) = validate(@_, 'ids', 'names', 'type'); + + Bugzilla->login(LOGIN_REQUIRED); + + # Reject access if there is no sense in continuing. + my $user = Bugzilla->user; + my $all_groups = $user->in_group('editusers') || $user->in_group('creategroups'); + if (!$all_groups && !$user->can_bless) { + ThrowUserError('group_cannot_view'); + } + + Bugzilla->switch_to_shadow_db(); + + my $groups = []; + + if (defined $params->{ids}) { + # Get the groups by id + $groups = Bugzilla::Group->new_from_list($params->{ids}); + } + + if (defined $params->{names}) { + # Get the groups by name. Check will throw an error if a bad name is given + foreach my $name (@{$params->{names}}) { + # Skip if we got this from params->{id} + next if grep { $_->name eq $name } @$groups; + + push @$groups, Bugzilla::Group->check({ name => $name }); + } + } + + if (!defined $params->{ids} && !defined $params->{names}) { + if ($all_groups) { + @$groups = Bugzilla::Group->get_all; + } + else { + # Get only groups the user has bless groups too + $groups = $user->bless_groups; + } + } + + # Now create a result entry for each. + my @groups = map { $self->_group_to_hash($params, $_) } @$groups; + return { groups => \@groups }; +} + +sub _group_to_hash { + my ($self, $params, $group) = @_; + my $user = Bugzilla->user; + + my $field_data = { + id => $self->type('int', $group->id), + name => $self->type('string', $group->name), + description => $self->type('string', $group->description), + }; + + if ($user->in_group('creategroups')) { + $field_data->{is_active} = $self->type('boolean', $group->is_active); + $field_data->{is_bug_group} = $self->type('boolean', $group->is_bug_group); + $field_data->{user_regexp} = $self->type('string', $group->user_regexp); + } + + if ($params->{membership}) { + $field_data->{membership} = $self->_get_group_membership($group, $params); + } + return $field_data; +} + +sub _get_group_membership { + my ($self, $group, $params) = @_; + my $user = Bugzilla->user; + + my %users_only; + my $dbh = Bugzilla->dbh; + my $editusers = $user->in_group('editusers'); + + my $query = 'SELECT userid FROM profiles'; + my $visibleGroups; + + if (!$editusers && Bugzilla->params->{'usevisibilitygroups'}) { + # Show only users in visible groups. + $visibleGroups = $user->visible_groups_inherited; + + if (scalar @$visibleGroups) { + $query .= qq{, user_group_map AS ugm + WHERE ugm.user_id = profiles.userid + AND ugm.isbless = 0 + AND } . $dbh->sql_in('ugm.group_id', $visibleGroups); + } + } elsif ($editusers || $user->can_bless($group->id) || $user->in_group('creategroups')) { + $visibleGroups = 1; + $query .= qq{, user_group_map AS ugm + WHERE ugm.user_id = profiles.userid + AND ugm.isbless = 0 + }; + } + if (!$visibleGroups) { + ThrowUserError('group_not_visible', { group => $group }); + } + + my $grouplist = Bugzilla::Group->flatten_group_membership($group->id); + $query .= ' AND ' . $dbh->sql_in('ugm.group_id', $grouplist); + + my $userids = $dbh->selectcol_arrayref($query); + my $user_objects = Bugzilla::User->new_from_list($userids); + my @users = + map {{ + id => $self->type('int', $_->id), + real_name => $self->type('string', $_->name), + name => $self->type('string', $_->login), + email => $self->type('string', $_->email), + can_login => $self->type('boolean', $_->is_enabled), + email_enabled => $self->type('boolean', $_->email_enabled), + login_denied_text => $self->type('string', $_->disabledtext), + }} @$user_objects; + + return \@users; +} + 1; __END__ @@ -116,6 +239,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 and Modification =head2 create @@ -128,9 +255,16 @@ B<UNSTABLE> This allows you to create a new group in Bugzilla. -=item B<Params> +=item B<REST> + +POST /rest/group + +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 +Some params must be set, or an error will be thrown. These params are marked B<Required>. =over @@ -151,7 +285,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 @@ -165,7 +299,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. @@ -191,7 +325,15 @@ 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 =head2 update @@ -203,6 +345,14 @@ B<UNSTABLE> This allows you to update a group in Bugzilla. +=item B<REST> + +PUT /rest/group/<group_name_or_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> At least C<ids> or C<names> must be set, or an error will be thrown. @@ -281,6 +431,176 @@ comma-and-space-separated list if multiple values were removed. The same as L</create>. +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + +=back + +=head1 Group Information + +=head2 get + +B<UNSTABLE> + +=over + +=item B<Description> + +Returns information about L<Bugzilla::Group|Groups>. + +=item B<REST> + +To return information about a specific group by C<id> or C<name>: + +GET /rest/group/<group_id_or_name> + +You can also return information about more than one specific group +by using the following in your query string: + +GET /rest/group?ids=1&ids=2&ids=3 or GET /group?names=ProductOne&names=Product2 + +the returned data format is same as below. + +=item B<Params> + +If neither ids or names is passed, and you are in the creategroups or +editusers group, then all groups will be retrieved. Otherwise, only groups +that you have bless privileges for will be returned. + +=over + +=item C<ids> + +C<array> Contain ids of groups to update. + +=item C<names> + +C<array> Contain names of groups to update. + +=item C<membership> + +C<boolean> Set to 1 then a list of members of the passed groups' names and +ids will be returned. + +=back + +=item B<Returns> + +If the user is a member of the "creategroups" group they will receive +information about all groups or groups matching the criteria that they passed. +You have to be in the creategroups group unless you're requesting membership +information. + +If the user is not a member of the "creategroups" group, but they are in the +"editusers" group or have bless privileges to the groups they require +membership information for, the is_active, is_bug_group and user_regexp values +are not supplied. + +The return value will be a hash containing group names as the keys, each group +name will point to a hash that describes the group and has the following items: + +=over + +=item id + +C<int> The unique integer ID that Bugzilla uses to identify this group. +Even if the name of the group changes, this ID will stay the same. + +=item name + +C<string> The name of the group. + +=item description + +C<string> The description of the group. + +=item is_bug_group + +C<int> Whether this groups is to be used for bug reports or is only administrative specific. + +=item user_regexp + +C<string> A regular expression that allows users to be added to this group if their login matches. + +=item is_active + +C<int> Whether this group is currently active or not. + +=item users + +C<array> An array of hashes, each hash contains a user object for one of the +members of this group, only returned if the user sets the C<membership> +parameter to 1, the user hash has the following items: + +=over + +=item id + +C<int> The id of the user. + +=item real_name + +C<string> The actual name of the user. + +=item email + +C<string> The email address of the user. + +=item name + +C<string> The login name of the user. Note that in some situations this is +different than their email. + +=item can_login + +C<boolean> A boolean value to indicate if the user can login into bugzilla. + +=item email_enabled + +C<boolean> A boolean value to indicate if bug-related mail will be sent +to the user or not. + +=item disabled_text + +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. + +=back + +=back + +=item B<Errors> + +=over + +=item 51 (Invalid Object) + +A non existing group name was passed to the function, as a result no +group object existed for that invalid name. + +=item 805 (Cannot view groups) + +Logged-in users are not authorized to edit bugzilla groups as they are not +members of the creategroups group in bugzilla, or they are not authorized to +access group member's information as they are not members of the "editusers" +group or can bless the group. + +=back + +=item B<History> + +=over + +=item This function was added in Bugzilla B<5.0>. + +=back + =back =cut diff --git a/Bugzilla/WebService/Product.pm b/Bugzilla/WebService/Product.pm index 1c8d75bb4..f38972bc1 100644 --- a/Bugzilla/WebService/Product.pm +++ b/Bugzilla/WebService/Product.pm @@ -7,8 +7,11 @@ package Bugzilla::WebService::Product; +use 5.10.1; use strict; -use base qw(Bugzilla::WebService); +use warnings; + +use parent qw(Bugzilla::WebService); use Bugzilla::Product; use Bugzilla::User; use Bugzilla::Error; @@ -57,64 +60,92 @@ BEGIN { *get_products = \&get } # Get the ids of the products the user can search sub get_selectable_products { Bugzilla->switch_to_shadow_db(); - return {ids => [map {$_->id} @{Bugzilla->user->get_selectable_products}]}; + 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 { Bugzilla->switch_to_shadow_db(); - return {ids => [map {$_->id} @{Bugzilla->user->get_enterable_products}]}; + 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 { Bugzilla->switch_to_shadow_db(); - return {ids => [map {$_->id} @{Bugzilla->user->get_accessible_products}]}; + 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'); + my ($self, $params) = validate(@_, 'ids', 'names', 'type'); + my $user = Bugzilla->user; - defined $params->{ids} || defined $params->{names} + defined $params->{ids} || defined $params->{names} || defined $params->{type} || ThrowCodeError("params_required", { function => "Product.get", - params => ['ids', 'names'] }); + params => ['ids', 'names', 'type'] }); Bugzilla->switch_to_shadow_db(); - # Only products that are in the users accessible products, - # can be allowed to be returned - my $accessible_products = Bugzilla->user->get_accessible_products; + 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 - # accessible products. - push(@requested_accessible, - grep { $ids{$_->id} } @$accessible_products); + + # Return the intersection of this, by grepping the ids from $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 - # accessible products, union'ed with products found by ID to + + # Return the intersection of this, by grepping the names + # from $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 }; } @@ -122,7 +153,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"}); @@ -158,7 +189,7 @@ sub update { object => "products" }); defined($params->{names}) || defined($params->{ids}) - || ThrowCodeError('params_required', + || ThrowCodeError('params_required', { function => 'Product.update', params => ['ids', 'names'] }); my $product_objects = params_to_objects($params, 'Bugzilla::Product'); @@ -177,10 +208,10 @@ sub update { my %changes; foreach my $product (@$product_objects) { my $returned_changes = $product->update(); - $changes{$product->id} = translate($returned_changes, MAPPED_RETURNS); + $changes{$product->id} = translate($returned_changes, MAPPED_RETURNS); } $dbh->bz_commit_transaction(); - + my @result; foreach my $product (@$product_objects) { my %hash = ( @@ -192,7 +223,7 @@ sub update { my $change = $changes{$product->id}->{$field}; $hash{changes}{$field} = { removed => $self->type('string', $change->[0]), - added => $self->type('string', $change->[1]) + added => $self->type('string', $change->[1]) }; } @@ -234,7 +265,7 @@ sub _product_to_hash { sub _component_to_hash { my ($self, $component, $params) = @_; - my $field_data = { + my $field_data = filter $params, { id => $self->type('int', $component->id), name => @@ -242,17 +273,17 @@ sub _component_to_hash { description => $self->type('string' , $component->description), default_assigned_to => - $self->type('string' , $component->default_assignee->login), - default_qa_contact => - $self->type('string' , $component->default_qa_contact ? - $component->default_qa_contact->login : ''), + $self->type('email', $component->default_assignee->login), + default_qa_contact => + $self->type('email', $component->default_qa_contact ? + $component->default_qa_contact->login : ""), sort_key => # sort_key is returned to match Bug.fields 0, is_active => $self->type('boolean', $component->is_active), - }; + }, undef, 'components'; - if (filter_wants($params, 'flag_types', 'components')) { + if (filter_wants($params, 'flag_types', undef, 'components')) { $field_data->{flag_types} = { bug => [map { @@ -264,12 +295,13 @@ sub _component_to_hash { } @{$component->flag_types->{'attachment'}}], }; } - return filter($params, $field_data, 'components'); + + return $field_data; } sub _flag_type_to_hash { - my ($self, $flag_type) = @_; - return { + my ($self, $flag_type, $params) = @_; + return filter $params, { id => $self->type('int', $flag_type->id), name => @@ -292,12 +324,12 @@ sub _flag_type_to_hash { $self->type('int', $flag_type->grant_group_id), request_group => $self->type('int', $flag_type->request_group_id), - }; + }, undef, 'flag_types'; } sub _version_to_hash { my ($self, $version, $params) = @_; - my $field_data = { + return filter $params, { id => $self->type('int', $version->id), name => @@ -306,13 +338,12 @@ sub _version_to_hash { 0, is_active => $self->type('boolean', $version->is_active), - }; - return filter($params, $field_data, 'versions'); + }, undef, 'versions'; } sub _milestone_to_hash { my ($self, $milestone, $params) = @_; - my $field_data = { + return filter $params, { id => $self->type('int', $milestone->id), name => @@ -321,8 +352,7 @@ sub _milestone_to_hash { $self->type('int', $milestone->sortkey), is_active => $self->type('boolean', $milestone->is_active), - }; - return filter($params, $field_data, 'milestones'); + }, undef, 'milestones'; } 1; @@ -343,6 +373,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 @@ -355,15 +389,29 @@ B<EXPERIMENTAL> Returns a list of the ids of the products the user can search on. +=item B<REST> + +GET /rest/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 @@ -377,6 +425,12 @@ B<EXPERIMENTAL> Returns a list of the ids of the products the user can enter bugs against. +=item B<REST> + +GET /rest/product_enterable + +the returned data format is same as below. + =item B<Params> (none) =item B<Returns> @@ -386,6 +440,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 @@ -399,6 +461,12 @@ B<UNSTABLE> Returns a list of the ids of the products the user can search or enter bugs against. +=item B<REST> + +GET /rest/product_accessible + +the returned data format is same as below. + =item B<Params> (none) =item B<Returns> @@ -408,6 +476,14 @@ ids. =item B<Errors> (none) +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head2 get @@ -424,6 +500,24 @@ B<Note>: You must at least specify one of C<ids> or C<names>. B<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 /rest/product?type=accessible + +To return information about a specific product by C<id> or C<name>: + +GET /rest/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 /rest/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 @@ -442,9 +536,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: @@ -524,7 +624,7 @@ 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 +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: @@ -578,8 +678,8 @@ 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 +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 @@ -619,6 +719,8 @@ been removed. =item In Bugzilla B<4.4>, C<flag_types> was added to the fields returned by C<get>. +=item REST API call added in Bugzilla B<5.0>. + =back =back @@ -635,9 +737,16 @@ B<EXPERIMENTAL> This allows you to create a new product in Bugzilla. -=item B<Params> +=item B<REST> + +POST /rest/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 +Some params must be set, or an error will be thrown. These params are marked B<Required>. =over @@ -651,11 +760,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. @@ -664,11 +773,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. @@ -680,7 +789,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. @@ -716,6 +825,14 @@ 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 =head2 update @@ -728,6 +845,14 @@ B<EXPERIMENTAL> This allows you to update a product in Bugzilla. +=item B<REST> + +PUT /rest/product/<product_id_or_name> + +The params to include in the PUT body as well as the returned data format, +are the same as below. The C<ids> and C<names> params will be overridden as +it is pulled from the URL path. + =item B<Params> B<Note:> The following parameters specify which products you are updating. @@ -808,7 +933,7 @@ Note that booleans will be represented with the strings '1' and '0'. Here's an example of what a return value might look like: - { + { products => [ { id => 123, @@ -862,6 +987,16 @@ You must define a default milestone. =item Added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + +=back + =back +=head1 B<Methods in need of POD> + +=over + +=item get_products + =back diff --git a/Bugzilla/WebService/README b/Bugzilla/WebService/README index bbe320979..eb4799cfc 100644 --- a/Bugzilla/WebService/README +++ b/Bugzilla/WebService/README @@ -11,7 +11,7 @@ When XMLRPC::Lite calls a method, $self is the name of the *class* the method is in. For example, if we call Bugzilla.version(), the first argument is Bugzilla::WebService::Bugzilla. So in order to have $self (our first argument) act correctly in XML-RPC, we make all WebService -classes use base qw(Bugzilla::WebService). +classes use parent qw(Bugzilla::WebService). When JSON::RPC calls a method, $self is the JSON-RPC *server object*. In other words, it's an instance of Bugzilla::WebService::Server::JSONRPC. So we have diff --git a/Bugzilla/WebService/Server.pm b/Bugzilla/WebService/Server.pm index 15bc4bcca..7950c7a3b 100644 --- a/Bugzilla/WebService/Server.pm +++ b/Bugzilla/WebService/Server.pm @@ -6,12 +6,18 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::WebService::Server; + +use 5.10.1; use strict; +use warnings; 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) = @_; @@ -23,11 +29,15 @@ sub handle_login { return if ($class->login_exempt($method) and !defined Bugzilla->input_params->{Bugzilla_login}); Bugzilla->login(); + + Bugzilla::Hook::process( + 'webservice_before_call', + { 'method' => $method, full_method => $full_method }); } sub datetime_format_inbound { my ($self, $time) = @_; - + my $converted = datetime_from($time, Bugzilla->local_timezone); if (!defined $converted) { ThrowUserError('illegal_date', { date => $time }); @@ -53,4 +63,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 0a0afd400..70b8fd96c 100644 --- a/Bugzilla/WebService/Server/JSONRPC.pm +++ b/Bugzilla/WebService/Server/JSONRPC.pm @@ -7,7 +7,10 @@ package Bugzilla::WebService::Server::JSONRPC; +use 5.10.1; use strict; +use warnings; + use Bugzilla::WebService::Server; BEGIN { our @ISA = qw(Bugzilla::WebService::Server); @@ -24,7 +27,7 @@ BEGIN { use Bugzilla::Error; use Bugzilla::WebService::Constants; use Bugzilla::WebService::Util qw(taint_data fix_credentials); -use Bugzilla::Util qw(correct_urlbase trim disable_utf8); +use Bugzilla::Util; use HTTP::Message; use MIME::Base64 qw(decode_base64 encode_base64); @@ -74,6 +77,7 @@ sub response_header { sub response { my ($self, $response) = @_; + my $cgi = $self->cgi; # Implement JSONP. if (my $callback = $self->_bz_callback) { @@ -95,9 +99,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 @@ -209,6 +222,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; } @@ -254,7 +270,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 @@ -577,3 +603,25 @@ the JSON-RPC library that Bugzilla uses, not by Bugzilla. =head1 SEE ALSO L<Bugzilla::WebService> + +=head1 B<Methods in need of POD> + +=over + +=item response + +=item response_header + +=item cgi + +=item retrieve_json_from_get + +=item create_json_coder + +=item type + +=item handle_login + +=item datetime_format_outbound + +=back diff --git a/Bugzilla/WebService/Server/REST.pm b/Bugzilla/WebService/Server/REST.pm new file mode 100644 index 000000000..8450a7a28 --- /dev/null +++ b/Bugzilla/WebService/Server/REST.pm @@ -0,0 +1,689 @@ +# 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 warnings; + +use parent qw(Bugzilla::WebService::Server::JSONRPC); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Hook; +use Bugzilla::Util qw(correct_urlbase html_quote); +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Util qw(taint_data fix_credentials); + +# 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::Component; +use Bugzilla::WebService::Server::REST::Resources::FlagType; +use Bugzilla::WebService::Server::REST::Resources::Group; +use Bugzilla::WebService::Server::REST::Resources::Product; +use Bugzilla::WebService::Server::REST::Resources::User; +use Bugzilla::WebService::Server::REST::Resources::BugUserLastVisit; + +use List::MoreUtils qw(uniq); +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. + # If no accept header was sent or the content types specified were not + # matched, we default to the first type in the whitelist. + $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); + $result->{documentation} = REST_DOC; + delete $result->{'name'}; # Remove JSONRPCError + } + elsif (exists $json_data->{result}) { + $result = $json_data->{result}; + } + + # The result needs to be a valid JSON data structure + # and not a undefined or scalar value. + if (!ref $result + || blessed($result) + || (ref $result ne 'HASH' && ref $result ne 'ARRAY')) + { + $result = { result => $result }; + } + + Bugzilla::Hook::process('webservice_rest_response', + { rpc => $self, result => \$result, response => $response }); + + # Access Control + $response->header("Access-Control-Allow-Origin", "*"); + $response->header("Access-Control-Allow-Headers", "origin, content-type, accept, x-requested-with"); + + # 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); + + # 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; + + # Allow extensions to modify the params post login + Bugzilla::Hook::process('webservice_rest_request', + { rpc => $self, params => $params }); + + 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) = @_; + + 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 parameters we were able to pull out of the path + # based on the resource regexp and combine with the normal URL + # parameters. + if (my $rest_params = $self->bz_rest_params) { + foreach my $param (keys %$rest_params) { + # If the param does not already exist or if the + # rest param is a single value, add it to the + # global params. + if (!exists $params->{$param} || !ref $rest_params->{$param}) { + $params->{$param} = $rest_params->{$param}; + } + # If rest_param is a list then add any extra values to the list + elsif (ref $rest_params->{$param}) { + my @extra_values = ref $params->{$param} + ? @{ $params->{$param} } + : ($params->{$param}); + $params->{$param} + = [ uniq (@{ $rest_params->{$param} }, @extra_values) ]; + } + } + } + + # Any parameters passed in in the body of a non-GET request will override + # any parameters pull from the url path. Otherwise non-unique keys are + # combined. + if ($self->request->method ne 'GET') { + my $extra_params = {}; + # We do this manually because CGI.pm doesn't understand JSON strings. + 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 non-GET. + # Note: parameters in query string body override any matching + # parameters 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; + } + + Bugzilla::Hook::process('webservice_rest_resources', + { rpc => $self, resources => $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(); + # Return the types as-is if no accept header sent, since sorting will be a no-op. + if (!@accept_types) { + return @types; + } + 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; +} + +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. For example, if your Bugzilla is at +C<bugzilla.yourdomain.com>, to access the API and load a bug, +you would use C<http://bugzilla.yourdomain.com/rest.cgi/bug/35>. + +If using Apache and mod_rewrite is installed and enabled, you can +simplify the endpoint by changing /rest.cgi/ to something like /rest/ +or something similar. So the same example from above would be: +C<http://bugzilla.yourdomain.com/rest/bug/35> which is simpler to remember. + +Add this to your .htaccess file: + + <IfModule mod_rewrite.c> + RewriteEngine On + RewriteRule ^rest/(.*)$ rest.cgi/$1 [NE] + </IfModule> + +=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..3fa8b65cf --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Bug.pm @@ -0,0 +1,179 @@ +# 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 warnings; + +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' + } + }, + 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/BugUserLastVisit.pm b/Bugzilla/WebService/Server/REST/Resources/BugUserLastVisit.pm new file mode 100644 index 000000000..8502d6b3b --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/BugUserLastVisit.pm @@ -0,0 +1,52 @@ +# 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::BugUserLastVisit; + +use 5.10.1; +use strict; +use warnings; + +BEGIN { + *Bugzilla::WebService::BugUserLastVisit::rest_resources = \&_rest_resources; +} + +sub _rest_resources { + return [ + # bug-id + qr{^/bug_user_last_visit/(\d+)$}, { + GET => { + method => 'get', + params => sub { + return { ids => [$_[0]] }; + }, + }, + POST => { + method => 'update', + params => sub { + return { ids => [$_[0]] }; + }, + }, + }, + ]; +} + +1; +__END__ + +=head1 NAME + +Bugzilla::Webservice::Server::REST::Resources::BugUserLastVisit - The +BugUserLastVisit REST API + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to lookup and update the last time +a user visited a bug. + +See L<Bugzilla::WebService::BugUserLastVisit> 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..a8f3f9330 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Bugzilla.pm @@ -0,0 +1,70 @@ +# 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 warnings; + +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..3f8d32a03 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Classification.pm @@ -0,0 +1,50 @@ +# 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 warnings; + +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::Classification> for more details on how to use this part +of the REST API. diff --git a/Bugzilla/WebService/Server/REST/Resources/Component.pm b/Bugzilla/WebService/Server/REST/Resources/Component.pm new file mode 100644 index 000000000..198c09332 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Component.pm @@ -0,0 +1,48 @@ +# 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::Component; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Component; + +use Bugzilla::Error; + +BEGIN { + *Bugzilla::WebService::Component::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/component$}, { + POST => { + method => 'create', + success_code => STATUS_CREATED + } + }, + ]; + return $rest_resources; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Webservice::Server::REST::Resources::Component - The Component REST API + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you create Components. + +See L<Bugzilla::WebService::Component> for more details on how to use this +part of the REST API. diff --git a/Bugzilla/WebService/Server/REST/Resources/FlagType.pm b/Bugzilla/WebService/Server/REST/Resources/FlagType.pm new file mode 100644 index 000000000..21dad0f73 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/FlagType.pm @@ -0,0 +1,72 @@ +# 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::FlagType; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::FlagType; + +use Bugzilla::Error; + +BEGIN { + *Bugzilla::WebService::FlagType::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/flag_type$}, { + POST => { + method => 'create', + success_code => STATUS_CREATED + } + }, + qr{^/flag_type/([^/]+)/([^/]+)$}, { + GET => { + method => 'get', + params => sub { + return { product => $_[0], + component => $_[1] }; + } + } + }, + qr{^/flag_type/([^/]+)$}, { + GET => { + method => 'get', + params => sub { + return { product => $_[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::FlagType - The Flag Type REST API + +=head1 DESCRIPTION + +This part of the Bugzilla REST API allows you to create and update Flag types. + +See L<Bugzilla::WebService::FlagType> 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..b052e384b --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Group.pm @@ -0,0 +1,60 @@ +# 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 warnings; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Group; + +BEGIN { + *Bugzilla::WebService::Group::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/group$}, { + GET => { + method => 'get' + }, + 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..607b94b53 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/Product.pm @@ -0,0 +1,83 @@ +# 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 warnings; + +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::Product> 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..a83109e73 --- /dev/null +++ b/Bugzilla/WebService/Server/REST/Resources/User.pm @@ -0,0 +1,81 @@ +# 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 warnings; + +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::User; + +BEGIN { + *Bugzilla::WebService::User::rest_resources = \&_rest_resources; +}; + +sub _rest_resources { + my $rest_resources = [ + qr{^/login$}, { + GET => { + method => 'login' + } + }, + qr{^/logout$}, { + GET => { + method => 'logout' + } + }, + qr{^/valid_login$}, { + GET => { + method => 'valid_login' + } + }, + 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::User> 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 5f9cb4515..98a0ee405 100644 --- a/Bugzilla/WebService/Server/XMLRPC.pm +++ b/Bugzilla/WebService/Server/XMLRPC.pm @@ -7,7 +7,10 @@ package Bugzilla::WebService::Server::XMLRPC; +use 5.10.1; use strict; +use warnings; + use XMLRPC::Transport::HTTP; use Bugzilla::WebService::Server; if ($ENV{MOD_PERL}) { @@ -18,11 +21,12 @@ if ($ENV{MOD_PERL}) { use Bugzilla::WebService::Constants; use Bugzilla::Error; +use Bugzilla::Util; use List::MoreUtils qw(none); -# 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') { @@ -31,8 +35,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 { @@ -46,22 +61,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 { @@ -85,8 +116,12 @@ sub handle_login { # This exists to validate input parameters (which XMLRPC::Lite doesn't do) # and also, in some cases, to more-usefully decode them. package Bugzilla::XMLRPC::Deserializer; + +use 5.10.1; use strict; -# We can't use "use base" because XMLRPC::Serializer doesn't return +use warnings; + +# We can't use "use parent" because XMLRPC::Serializer doesn't return # a true value. use XMLRPC::Lite; our @ISA = qw(XMLRPC::Deserializer); @@ -96,6 +131,15 @@ use Bugzilla::WebService::Constants qw(XMLRPC_CONTENT_TYPE_WHITELIST); use Bugzilla::WebService::Util qw(fix_credentials); use Scalar::Util qw(tainted); +sub new { + my $self = shift->SUPER::new(@_); + # Initialise XML::Parser to not expand references to entities, to prevent DoS + require XML::Parser; + my $parser = XML::Parser->new( NoExpand => 1, Handlers => { Default => sub {} } ); + $self->{_parser}->parser($parser, $parser); + return $self; +} + sub deserialize { my $self = shift; @@ -123,6 +167,7 @@ sub deserialize { fix_credentials($params); Bugzilla->input_params($params); + return $som; } @@ -186,7 +231,11 @@ sub _validation_subs { 1; package Bugzilla::XMLRPC::SOM; + +use 5.10.1; use strict; +use warnings; + use XMLRPC::Lite; our @ISA = qw(XMLRPC::SOM); use Bugzilla::WebService::Util qw(taint_data); @@ -209,9 +258,13 @@ sub paramsin { # This package exists to fix a UTF-8 bug in SOAP::Lite. # See http://rt.cpan.org/Public/Bug/Display.html?id=32952. package Bugzilla::XMLRPC::Serializer; -use Scalar::Util qw(blessed); + +use 5.10.1; use strict; -# We can't use "use base" because XMLRPC::Serializer doesn't return +use warnings; + +use Scalar::Util qw(blessed reftype); +# We can't use "use parent" because XMLRPC::Serializer doesn't return # a true value. use XMLRPC::Lite; our @ISA = qw(XMLRPC::Serializer); @@ -244,8 +297,8 @@ sub envelope { my $self = shift; my ($type, $method, $data) = @_; # If the type isn't a successful response we don't want to change the values. - if ($type eq 'response'){ - $data = _strip_undefs($data); + if ($type eq 'response') { + _strip_undefs($data); } return $self->SUPER::envelope($type, $method, $data); } @@ -256,7 +309,9 @@ sub envelope { # so it cannot be recursed like the other hash type objects. sub _strip_undefs { my ($initial) = @_; - if (ref $initial eq "HASH" || (blessed $initial && $initial->isa("HASH"))) { + my $type = reftype($initial) or return; + + if ($type eq "HASH") { while (my ($key, $value) = each(%$initial)) { if ( !defined $value || (blessed $value && $value->isa('XMLRPC::Data') && !defined $value->value) ) @@ -265,11 +320,11 @@ sub _strip_undefs { delete $initial->{$key}; } else { - $initial->{$key} = _strip_undefs($value); + _strip_undefs($value); } } } - if (ref $initial eq "ARRAY" || (blessed $initial && $initial->isa("ARRAY"))) { + elsif ($type eq "ARRAY") { for (my $count = 0; $count < scalar @{$initial}; $count++) { my $value = $initial->[$count]; if ( !defined $value @@ -280,11 +335,10 @@ sub _strip_undefs { $count--; } else { - $initial->[$count] = _strip_undefs($value); + _strip_undefs($value); } } } - return $initial; } sub BEGIN { @@ -386,3 +440,15 @@ perl-SOAP-Lite package in versions 0.68-1 and above. =head1 SEE ALSO L<Bugzilla::WebService> + +=head1 B<Methods in need of POD> + +=over + +=item make_response + +=item initialize + +=item handle_login + +=back diff --git a/Bugzilla/WebService/User.pm b/Bugzilla/WebService/User.pm index 5a7f25036..0ae76d70f 100644 --- a/Bugzilla/WebService/User.pm +++ b/Bugzilla/WebService/User.pm @@ -7,20 +7,20 @@ package Bugzilla::WebService::User; +use 5.10.1; use strict; -use base qw(Bugzilla::WebService); +use warnings; + +use parent qw(Bugzilla::WebService); -use Bugzilla; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Group; use Bugzilla::User; use Bugzilla::Util qw(trim detaint_natural); -use Bugzilla::WebService::Util qw(filter validate translate params_to_objects); - -use List::Util qw(min); +use Bugzilla::WebService::Util qw(filter filter_wants validate translate params_to_objects); -use List::Util qw(first); +use List::Util qw(first min); # Don't need auth to login use constant LOGIN_EXEMPT => { @@ -39,20 +39,19 @@ use constant PUBLIC_METHODS => qw( logout offer_account_by_email update + valid_login ); use constant MAPPED_FIELDS => { email => 'login', full_name => 'name', login_denied_text => 'disabledtext', - email_enabled => 'disable_mail' }; use constant MAPPED_RETURNS => { login_name => 'email', realname => 'full_name', disabledtext => 'login_denied_text', - disable_mail => 'email_enabled' }; ############## @@ -83,6 +82,17 @@ sub logout { Bugzilla->logout; } +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); +} + ################# # User Creation # ################# @@ -127,7 +137,7 @@ 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(); @@ -157,11 +167,11 @@ sub get { } my $in_group = $self->_filter_users_by_group( \@user_objects, $params); - @users = map {filter $params, { + @users = map { filter $params, { id => $self->type('int', $_->id), - real_name => $self->type('string', $_->name), - name => $self->type('string', $_->login), - }} @$in_group; + real_name => $self->type('string', $_->name), + name => $self->type('email', $_->login), + } } @$in_group; return { users => \@users }; } @@ -169,7 +179,7 @@ sub get { my $obj_by_ids; $obj_by_ids = Bugzilla::User->new_from_list($params->{ids}) if $params->{ids}; - # obj_by_ids are only visible to the user if he can see + # obj_by_ids are only visible to the user if they can see # the otheruser, for non visible otheruser throw an error foreach my $obj (@$obj_by_ids) { if (Bugzilla->user->can_see_user($obj)){ @@ -207,15 +217,13 @@ sub get { } } - my $in_group = $self->_filter_users_by_group( - \@user_objects, $params); - + my $in_group = $self->_filter_users_by_group(\@user_objects, $params); foreach my $user (@$in_group) { - my $user_info = { + my $user_info = filter $params, { id => $self->type('int', $user->id), real_name => $self->type('string', $user->name), - name => $self->type('string', $user->login), - email => $self->type('string', $user->email), + name => $self->type('email', $user->login), + email => $self->type('email', $user->email), can_login => $self->type('boolean', $user->is_enabled ? 1 : 0), }; @@ -225,18 +233,30 @@ sub get { } if (Bugzilla->user->id == $user->id) { - $user_info->{saved_searches} = [map { $self->_query_to_hash($_) } @{ $user->queries }]; - $user_info->{saved_reports} = [map { $self->_report_to_hash($_) } @{ $user->reports }]; + if (filter_wants($params, 'saved_searches')) { + $user_info->{saved_searches} = [ + map { $self->_query_to_hash($_) } @{ $user->queries } + ]; + } + if (filter_wants($params, 'saved_reports')) { + $user_info->{saved_reports} = [ + map { $self->_report_to_hash($_) } @{ $user->reports } + ]; + } } - if (Bugzilla->user->id == $user->id || Bugzilla->user->in_group('editusers')) { - $user_info->{groups} = [map {$self->_group_to_hash($_)} @{ $user->groups }]; - } - else { - $user_info->{groups} = $self->_filter_bless_groups($user->groups); + if (filter_wants($params, 'groups')) { + if (Bugzilla->user->id == $user->id || Bugzilla->user->in_group('editusers')) { + $user_info->{groups} = [ + map { $self->_group_to_hash($_) } @{ $user->groups } + ]; + } + else { + $user_info->{groups} = $self->_filter_bless_groups($user->groups); + } } - push(@users, filter($params, $user_info)); + push(@users, $user_info); } return { users => \@users }; @@ -296,6 +316,10 @@ sub update { # stays consistent for things that can become empty. $change->[0] = '' if !defined $change->[0]; $change->[1] = '' if !defined $change->[1]; + # We also flatten arrays (used by groups and blessed_groups) + $change->[0] = join(',', @{$change->[0]}) if ref $change->[0]; + $change->[1] = join(',', @{$change->[1]}) if ref $change->[1]; + $hash{changes}{$field} = { removed => $self->type('string', $change->[0]), added => $self->type('string', $change->[1]) @@ -413,11 +437,19 @@ 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 +These method are now deprecated, and will be removed in the release after +Bugzilla 5.0. The correct way of use these REST and RPC calls is noted in +L<Bugzilla::WebService> + =head2 login -B<STABLE> +B<DEPRECATED> =over @@ -431,7 +463,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. @@ -444,10 +476,10 @@ which called this method. =item B<Returns> 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. The token can be sent along with any future -requests to the webservice, for the duration of the session, i.e. till -L<User.logout|/logout> is called. +user that was logged in, and a C<token> which can be passed in +the parameters as authentication in other calls. The token can be sent +along with any future requests to the webservice, for the duration of the +session, i.e. till L<User.logout|/logout> is called. =item B<Errors> @@ -465,7 +497,7 @@ specified with the error. =item 305 (New Password Required) The current password is correct, but the user is asked to change -his password. +their password. =item 50 (Param Required) @@ -477,12 +509,14 @@ A login or password parameter was not provided. =over -=item C<remember> was removed in Bugzilla B<4.4> as this method no longer +=item C<remember> was removed in Bugzilla B<5.0> as this method no longer creates a login cookie. -=item C<restrict_login> was added in Bugzilla B<4.4>. +=item C<restrict_login> was added in Bugzilla B<5.0>. + +=item C<token> was added in Bugzilla B<4.4.3>. -=item C<token> was added in Bugzilla B<4.4>. +=item This function will be removed in the release after Bugzilla 5.0, in favour of API keys. =back @@ -490,7 +524,7 @@ creates a login cookie. =head2 logout -B<STABLE> +B<DEPRECATED> =over @@ -506,6 +540,52 @@ Log out the user. Does nothing if there is no user logged in. =back +=head2 valid_login + +B<DEPRECATED> + +=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>. + +=item This function will be removed in the release after Bugzilla 5.0, in favour of API keys. + +=back + +=back + =head1 Account Creation and Modification =head2 offer_account_by_email @@ -565,6 +645,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 /rest/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 @@ -608,6 +695,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 @@ -622,6 +711,14 @@ B<EXPERIMENTAL> Updates user accounts in Bugzilla. +=item B<REST> + +PUT /rest/user/<user_id_or_name> + +The params to include in the PUT body as well as the returned data format, +are the same as below. The C<ids> and C<names> params are overridden as they +are pulled from the URL path. + =item B<Params> =over @@ -659,6 +756,37 @@ 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 C<groups> + +C<hash> These specify the groups that this user is directly a member of. +To set these, you should pass a hash as the value. The hash may contain +the following fields: + +=over + +=item C<add> An array of C<int>s or C<string>s. The group ids or group names +that the user should be added to. + +=item C<remove> An array of C<int>s or C<string>s. The group ids or group names +that the user should be removed from. + +=item C<set> An array of C<int>s or C<string>s. An exact set of group ids +and group names that the user should be a member of. NOTE: This does not +remove groups from the user where the person making the change does not +have the bless privilege for. + +If you specify C<set>, then C<add> and C<remove> will be ignored. A group in +both the C<add> and C<remove> list will be added. Specifying a group that the +user making the change does not have bless rights will generate an error. + +=back + +=item C<bless_groups> + +C<hash> - This is the same as groups, but affects what groups a user +has direct membership to bless that group. It takes the same inputs as +groups. + =back =item B<Returns> @@ -708,6 +836,14 @@ Logged-in users are not authorized to edit other users. =back +=item B<History> + +=over + +=item REST API call added in Bugzilla B<5.0>. + +=back + =back =head1 User Info @@ -722,6 +858,18 @@ B<STABLE> Gets information about user accounts in Bugzilla. +=item B<REST> + +To get information about a single user: + +GET /rest/user/<user_id_or_name> + +To search for users by name, group using URL params same as below: + +GET /rest/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. @@ -832,7 +980,7 @@ disabled/closed. =item groups C<array> An array of group hashes the user is a member of. If the currently -logged in user is querying his own account or is a member of the 'editusers' +logged in user is querying their own account or is a member of the 'editusers' group, the array will contain all the groups that the user is a member of. Otherwise, the array will only contain groups that the logged in user can bless. Each hash describes the group and contains the following items: @@ -916,7 +1064,7 @@ group ID in the C<group_ids> argument. =item 52 (Invalid Parameter) -The value used must be an integer greater then zero. +The value used must be an integer greater than zero. =item 304 (Authorization Required) @@ -952,6 +1100,8 @@ illegal to pass a group name you don't belong to. =item C<groups>, C<saved_searches>, and C<saved_reports> were added in Bugzilla B<4.4>. +=item REST API call added in Bugzilla B<5.0>. + =back =back diff --git a/Bugzilla/WebService/Util.pm b/Bugzilla/WebService/Util.pm index c7d63b336..a0a51a8de 100644 --- a/Bugzilla/WebService/Util.pm +++ b/Bugzilla/WebService/Util.pm @@ -6,14 +6,25 @@ # defined by the Mozilla Public License, v. 2.0. package Bugzilla::WebService::Util; + +use 5.10.1; use strict; -use base qw(Exporter); +use warnings; + +use Bugzilla::Flag; +use Bugzilla::FlagType; +use Bugzilla::Error; + +use Storable qw(dclone); + +use parent qw(Exporter); # We have to "require", not "use" this, because otherwise it tries to # use features of Test::More during import(). require Test::Taint; our @EXPORT_OK = qw( + extract_flags filter filter_wants taint_data @@ -23,19 +34,93 @@ our @EXPORT_OK = qw( fix_credentials ); -sub filter ($$;$) { - my ($params, $hash, $prefix) = @_; +sub extract_flags { + my ($flags, $bug, $attachment) = @_; + my (@new_flags, @old_flags); + + my $flag_types = $attachment ? $attachment->flag_types : $bug->flag_types; + my $current_flags = $attachment ? $attachment->flags : $bug->flags; + + # Copy the user provided $flags as we may call extract_flags more than + # once when editing multiple bugs or attachments. + my $flags_copy = dclone($flags); + + foreach my $flag (@$flags_copy) { + my $id = $flag->{id}; + my $type_id = $flag->{type_id}; + + my $new = delete $flag->{new}; + my $name = delete $flag->{name}; + + if ($id) { + my $flag_obj = grep($id == $_->id, @$current_flags); + $flag_obj || ThrowUserError('object_does_not_exist', + { class => 'Bugzilla::Flag', id => $id }); + } + elsif ($type_id) { + my $type_obj = grep($type_id == $_->id, @$flag_types); + $type_obj || ThrowUserError('object_does_not_exist', + { class => 'Bugzilla::FlagType', id => $type_id }); + if (!$new) { + my @flag_matches = grep($type_id == $_->type->id, @$current_flags); + @flag_matches > 1 && ThrowUserError('flag_not_unique', + { value => $type_id }); + if (!@flag_matches) { + delete $flag->{id}; + } + else { + delete $flag->{type_id}; + $flag->{id} = $flag_matches[0]->id; + } + } + } + elsif ($name) { + my @type_matches = grep($name eq $_->name, @$flag_types); + @type_matches > 1 && ThrowUserError('flag_type_not_unique', + { value => $name }); + @type_matches || ThrowUserError('object_does_not_exist', + { class => 'Bugzilla::FlagType', name => $name }); + if ($new) { + delete $flag->{id}; + $flag->{type_id} = $type_matches[0]->id; + } + else { + my @flag_matches = grep($name eq $_->type->name, @$current_flags); + @flag_matches > 1 && ThrowUserError('flag_not_unique', { value => $name }); + if (@flag_matches) { + $flag->{id} = $flag_matches[0]->id; + } + else { + delete $flag->{id}; + $flag->{type_id} = $type_matches[0]->id; + } + } + } + + if ($flag->{id}) { + push(@old_flags, $flag); + } + else { + push(@new_flags, $flag); + } + } + + return (\@old_flags, \@new_flags); +} + +sub filter($$;$$) { + my ($params, $hash, $types, $prefix) = @_; my %newhash = %$hash; foreach my $key (keys %$hash) { - delete $newhash{$key} if !filter_wants($params, $key, $prefix); + delete $newhash{$key} if !filter_wants($params, $key, $types, $prefix); } return \%newhash; } -sub filter_wants ($$;$) { - my ($params, $field, $prefix) = @_; +sub filter_wants($$;$$) { + my ($params, $field, $types, $prefix) = @_; # Since this is operation is resource intensive, we will cache the results # This assumes that $params->{*_fields} doesn't change between calls @@ -46,28 +131,58 @@ sub filter_wants ($$;$) { return $cache->{$field}; } + # Mimic old behavior if no types provided + my %field_types = map { $_ => 1 } (ref $types ? @$types : ($types || 'default')); + my %include = map { $_ => 1 } @{ $params->{'include_fields'} || [] }; my %exclude = map { $_ => 1 } @{ $params->{'exclude_fields'} || [] }; - my $wants = 1; - if (defined $params->{exclude_fields} && $exclude{$field}) { - $wants = 0; + my %include_types; + my %exclude_types; + + # Only return default fields if nothing is specified + $include_types{default} = 1 if !%include; + + # Look for any field types requested + foreach my $key (keys %include) { + next if $key !~ /^_(.*)$/; + $include_types{$1} = 1; + delete $include{$key}; } - 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; - } + foreach my $key (keys %exclude) { + next if $key !~ /^_(.*)$/; + $exclude_types{$1} = 1; + delete $exclude{$key}; + } + + # Explicit inclusion/exclusion + return $cache->{$field} = 0 if $exclude{$field}; + return $cache->{$field} = 1 if $include{$field}; + + # If the user has asked to include all or exclude all + return $cache->{$field} = 0 if $exclude_types{'all'}; + return $cache->{$field} = 1 if $include_types{'all'}; + + # If the user has not asked for any fields specifically or if the user has asked + # for one or more of the field's types (and not excluded them) + foreach my $type (keys %field_types) { + return $cache->{$field} = 0 if $exclude_types{$type}; + return $cache->{$field} = 1 if $include_types{$type}; } - $cache->{$field} = $wants; - return $wants; + my $wants = 0; + if ($prefix) { + # Include the field if the parent is include (and this one is not excluded) + $wants = 1 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 = 1 if grep { substr($_, 0, $len) eq $key } keys %include; + } + + return $cache->{$field} = $wants; } sub taint_data { @@ -87,8 +202,9 @@ sub _delete_bad_keys { # Making something a hash key always untaints it, in Perl. # However, we need to validate our argument names in some way. # We know that all hash keys passed in to the WebService will - # match \w+, so we delete any key that doesn't match that. - if ($key !~ /^\w+$/) { + # match \w+, contain '.' or '-', so we delete any key that + # doesn't match that. + if ($key !~ /^[\w\.\-]+$/) { delete $item->{$key}; } } @@ -147,17 +263,25 @@ sub params_to_objects { sub fix_credentials { my ($params) = @_; # Allow user to pass in login=foo&password=bar as a convenience - # even if not calling User.login. We also do not delete them as - # User.login requires "login" and "password". + # 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'} = delete $params->{'login'}; $params->{'Bugzilla_password'} = delete $params->{'password'}; } + # Allow user to pass api_key=12345678 as a convenience which becomes + # "Bugzilla_api_key" which is what the auth code looks for. + if (exists $params->{api_key}) { + $params->{Bugzilla_api_key} = delete $params->{api_key}; + } # 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'} = delete $params->{'token'}; } + + # Allow extensions to modify the credential data before login + Bugzilla::Hook::process('webservice_fix_credentials', { params => $params }); } __END__ @@ -228,3 +352,17 @@ by both "ids" and "names". Returns an arrayref of objects. 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. + +=head2 extract_flags + +Subroutine that takes a list of hashes that are potential flag changes for +both bugs and attachments. Then breaks the list down into two separate lists +based on if the change is to add a new flag or to update an existing flag. + +=head1 B<Methods in need of POD> + +=over + +=item taint_data + +=back diff --git a/Bugzilla/Whine.pm b/Bugzilla/Whine.pm index 379619822..eeaea6da4 100644 --- a/Bugzilla/Whine.pm +++ b/Bugzilla/Whine.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Whine; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::Constants; use Bugzilla::Error; diff --git a/Bugzilla/Whine/Query.pm b/Bugzilla/Whine/Query.pm index e52cad66e..b2a2c9e07 100644 --- a/Bugzilla/Whine/Query.pm +++ b/Bugzilla/Whine/Query.pm @@ -7,9 +7,11 @@ package Bugzilla::Whine::Query; +use 5.10.1; use strict; +use warnings; -use base qw(Bugzilla::Object); +use parent qw(Bugzilla::Object); use Bugzilla::Constants; use Bugzilla::Search::Saved; @@ -121,3 +123,11 @@ bugs or one email per bug. The title of this object as it appears in the user forms and emails. =back + +=head1 B<Methods in need of POD> + +=over + +=item eventid + +=back diff --git a/Bugzilla/Whine/Schedule.pm b/Bugzilla/Whine/Schedule.pm index 102b5d825..11f0bf16f 100644 --- a/Bugzilla/Whine/Schedule.pm +++ b/Bugzilla/Whine/Schedule.pm @@ -5,11 +5,13 @@ # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. -use strict; - package Bugzilla::Whine::Schedule; -use base qw(Bugzilla::Object); +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Object); use Bugzilla::Constants; @@ -155,3 +157,11 @@ the group is still active, otherwise it will contain a single array element for the L<Bugzilla::User> in L</mailto>. =back + +=head1 B<Methods in need of POD> + +=over + +=item eventid + +=back |