diff --git a/Bugzilla/API/V1/Bug.pm b/Bugzilla/API/V1/Bug.pm new file mode 100644 index 0000000000..962dd63172 --- /dev/null +++ b/Bugzilla/API/V1/Bug.pm @@ -0,0 +1,1834 @@ +# 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::API::V1::Bug; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla::Comment; +use Bugzilla::Comment::TagWeights; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Field; +use Bugzilla::API::V1::Constants; +use Bugzilla::API::V1::Util + qw(extract_flags filter filter_wants validate translate); +use Bugzilla::Bug; +use Bugzilla::BugMail; +use Bugzilla::Util qw(trim detaint_natural remote_ip); +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(any uniq); +use Mojo::JSON; +use Storable qw(dclone); +use Types::Standard -all; +use Type::Utils; + +############# +# Constants # +############# + +use constant PRODUCT_SPECIFIC_FIELDS => qw(version target_milestone component); + +sub DATE_FIELDS { + my $fields = { + comments => ['new_since'], + create => [], + history => ['new_since'], + search => ['last_change_time', 'creation_time'], + update => [] + }; + + # Add date related custom fields + foreach my $field (Bugzilla->active_custom_fields({skip_extensions => 1})) { + next + unless ($field->type == FIELD_TYPE_DATETIME + || $field->type == FIELD_TYPE_DATE); + push @{$fields->{create}}, $field->name; + push @{$fields->{update}}, $field->name; + } + + return $fields; +} + +use constant BASE64_FIELDS => {add_attachment => ['data'],}; + +use constant ATTACHMENT_MAPPED_SETTERS => + {file_name => 'filename', summary => 'description',}; + +use constant ATTACHMENT_MAPPED_RETURNS => { + description => 'summary', + ispatch => 'is_patch', + isprivate => 'is_private', + isobsolete => 'is_obsolete', + filename => 'file_name', + mimetype => 'content_type', +}; + +our %api_field_types = ( + %{{map { $_ => 'double' } Bugzilla::Bug::NUMERIC_COLUMNS()}}, + %{{map { $_ => 'dateTime' } Bugzilla::Bug::DATE_COLUMNS()}}, +); + +our %api_field_names = reverse %{Bugzilla::Bug::FIELD_MAP()}; + +# This doesn't normally belong in FIELD_MAP, but we do want to translate +# "bug_group" back into "groups". +$api_field_names{'bug_group'} = 'groups'; + +########### +# Methods # +########### + +sub setup_routes { + my ($class, $r) = @_; + $r->get('/rest/bug')->to('V1::Bug#search'); + $r->post('/rest/bug')->to('V1::Bug#create'); + $r->get('/rest/possible_duplicates')->to('V1::Bug#possible_duplicates'); + $r->get('/rest/bug/:id')->to('V1::Bug#get'); + $r->put('/rest/bug/:id')->to('V1::Bug#update'); + $r->get('/rest/bug/:id/comment')->to('V1::Bug#comments'); + $r->post('/rest/bug/:id/comment')->to('V1::Bug#add_comment'); + $r->get('/rest/bug/comment/:comment_id')->to('V1::Bug#comments'); + $r->get('/rest/bug/comment/tags/:query')->to('V1::Bug#search_comment_tags'); + $r->put('/rest/bug/comment/:comment_id/tags') + ->to('V1::Bug#update_comment_tags'); + $r->post('/rest/bug/comment/render')->to('V1::Bug#render_comment'); + $r->get('/rest/bug/:id/history')->to('V1::Bug#history'); + $r->get('/rest/bug/:id/attachment')->to('V1::Bug#attachments'); + $r->post('/rest/bug/:id/attachment')->to('V1::Bug#add_attachment'); + $r->get('/rest/bug/attachment/:attach_id')->to('V1::Bug#attachments'); + $r->put('/rest/bug/attachment/:attach_id')->to('V1::Bug#update_attachment'); + $r->get('/rest/field/bug')->to('V1::Bug#fields'); + $r->get('/rest/field/bug/:name')->to('V1::Bug#fields'); + $r->get('/rest/bug/field/:field/values')->to('V1::Bug#legal_values'); + $r->get('/rest/bug/field/:field/:product_id/values') + ->to('V1::Bug#legal_values'); + $r->get('/rest/flag_types/:product')->to('V1::Bug#flag_types'); + $r->get('/rest/flag_types/:product/:component')->to('V1::Bug#flag_types'); +} + +sub fields { + my ($self) = @_; + my $params = $self->param->to_hash; + my $fields = []; + + Bugzilla->switch_to_shadow_db(); + + if (my $names = $params->{names}) { + foreach my $field (@{$names}) { + push @{$fields}, Bugzilla::Field->check($field); + } + } + else { + $fields = Bugzilla->fields({obsolete => 0}); + } + + my @fields_out; + foreach my $field (@{$fields}) { + my $visibility_field + = $field->visibility_field ? $field->visibility_field->name : undef; + my $vis_values = $field->visibility_values; + my $value_field = $field->value_field ? $field->value_field->name : undef; + + my (@values, $has_values); + if ( + ($field->is_select and $field->name ne 'product') or any { $_ eq $field->name }, + PRODUCT_SPECIFIC_FIELDS or $field->name eq 'keywords' + ) + { + $has_values = 1; + $values = $self->_legal_field_values({field => $field}); + } + + if (any { $_ eq $field->name }, PRODUCT_SPECIFIC_FIELDS) { + $value_field = 'product'; + } + + my %field_data = ( + id => $self->type('int', $field->id), + type => $self->type('int', $field->type), + is_custom => $self->type('boolean', $field->custom), + name => $self->type('string', $field->name), + display_name => $self->type('string', $field->description), + is_mandatory => $self->type('boolean', $field->is_mandatory), + is_on_bug_entry => $self->type('boolean', $field->enter_bug), + visibility_field => $self->type('string', $visibility_field), + visibility_values => [map { $self->type('string', $_->name) } @{$vis_values}], + + ); + if ($has_values) { + $field_data{value_field} = $self->type('string', $value_field); + $field_data{values} = $values; + } + push @fields_out, filter($params, \%field_data); + } + + return $self->render(json => {fields => \@fields_out}); +} + +sub _legal_field_values { + my ($self) = @_; + my $params = $self->param->to_hash; + my $field = $params->{field}; + my $field_name = $field->name; + my $user = Bugzilla->user; + + my @result; + if (any { $_ eq $field_name }, PRODUCT_SPECIFIC_FIELDS) { + my @list; + if ($field_name eq 'version') { + @list = Bugzilla::Version->get_all; + } + elsif ($field_name eq 'component') { + @list = Bugzilla::Component->get_all; + } + else { + @list = Bugzilla::Milestone->get_all; + } + + foreach my $value (@list) { + my $sortkey = $field_name eq 'target_milestone' ? $value->sortkey : 0; + + # XXX This is very slow for large numbers of values. + my $product_name = $value->product->name; + if ($user->can_see_product($product_name)) { + push( + @result, + { + name => $self->type('string', $value->name), + sort_key => $self->type('int', $sortkey), + sortkey => $self->type('int', $sortkey), # deprecated + visibility_values => [$self->type('string', $product_name)], + } + ); + } + } + } + + elsif ($field_name eq 'bug_status') { + my @status_all = Bugzilla::Status->get_all; + foreach my $status (@status_all) { + my @can_change_to; + foreach my $change_to (@{$status->can_change_to}) { + + # There's no need to note that a status can transition + # to itself. + next if $change_to->id == $status->id; + my %change_to_hash = ( + name => $self->type('string', $change_to->name), + comment_required => + $self->type('boolean', $change_to->comment_required_on_change_from($status)), + ); + push @can_change_to, \%change_to_hash; + } + + push @result, { + name => $self->type('string', $status->name), + is_open => $self->type('boolean', $status->is_open), + sort_key => $self->type('int', $status->sortkey), + sortkey => $self->type('int', $status->sortkey), # deprecated + can_change_to => \@can_change_to, + visibility_values => [], + }; + } + } + + elsif ($field_name eq 'keywords') { + my @legal_keywords = Bugzilla::Keyword->get_all; + foreach my $value (@legal_keywords) { + next unless $value->is_active; + push @result, + { + name => $self->type('string', $value->name), + description => $self->type('string', $value->description), + }; + } + } + else { + my @values = Bugzilla::Field::Choice->type($field)->get_all(); + foreach my $value (@values) { + my $vis_val = $value->visibility_value; + push @result, { + name => $self->type('string', $value->name), + sort_key => $self->type('int', $value->sortkey), + sortkey => $self->type('int', $value->sortkey), # deprecated + visibility_values => + [defined $vis_val ? $self->type('string', $vis_val->name) : ()], + }; + } + } + + return $self->render(json => \@result); +} + +sub comments { + my ($self) = @_; + my $params = $self->param->to_hash; + + if (!(defined $params->{ids} || defined $params->{comment_ids})) { + ThrowCodeError('params_required', + {function => 'Bug.comments', params => ['ids', 'comment_ids']}); + } + + my $bug_ids = $params->{ids} || []; + my $comment_ids = $params->{comment_ids} || []; + my $skip_private = $params->{skip_private} ? 1 : 0; + + my $dbh = Bugzilla->switch_to_shadow_db(); + my $user = Bugzilla->user; + + unless (Bugzilla->user->id) { + Bugzilla->check_rate_limit("get_comments", remote_ip()); + } + + if ($skip_private) { + + # 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+$/ } @$bug_ids; + $user->visible_bugs(\@int); + } + + my %bugs; + foreach my $bug_id (@$bug_ids) { + my $bug; + + if ($skip_private) { + $bug = Bugzilla::Bug->new({id => $bug_id, cache => 1}); + next if $bug->error || !$user->can_see_bug($bug->id); + } + else { + $bug = Bugzilla::Bug->check($bug_id); + } + + # We want the API to always return comments in the same order. + + my $comments + = $bug->comments({order => 'oldest_to_newest', after => $params->{new_since} + }); + my @result; + foreach my $comment (@$comments) { + next if $comment->is_private && !$user->is_insider; + push(@result, $self->_translate_comment($comment, $params)); + } + $bugs{$bug->id}{'comments'} = \@result; + } + + my %comments; + if (scalar @$comment_ids) { + my @ids = map { trim($_) } @$comment_ids; + my $comment_data = Bugzilla::Comment->new_from_list(\@ids); + + # See if we were passed any invalid comment ids. + my %got_ids = map { $_->id => 1 } @$comment_data; + foreach my $comment_id (@ids) { + if (!$got_ids{$comment_id}) { + ThrowUserError('comment_id_invalid', {id => $comment_id}); + } + } + + # Now make sure that we can see all the associated bugs. + my %got_bug_ids = map { $_->bug_id => 1 } @$comment_data; + Bugzilla::Bug->check($_) foreach (keys %got_bug_ids); + + foreach my $comment (@$comment_data) { + if ($comment->is_private && !$user->is_insider) { + ThrowUserError('comment_is_private', {id => $comment->id}); + } + $comments{$comment->id} = $self->_translate_comment($comment, $params); + } + } + + 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 $html + = Bugzilla->params->{use_markdown} + ? Bugzilla->markdown->render_html($params->{text}, $bug) + : Bugzilla::Template::quoteUrls($params->{text}, $bug); + + return {html => $html}; +} + +# Helper for Bug.comments +sub _translate_comment { + my ($self, $comment, $filters, $types, $prefix) = @_; + my $attach_id = $comment->is_about_attachment ? $comment->extra_data : undef; + + my $comment_hash = { + id => $self->type('int', $comment->id), + bug_id => $self->type('int', $comment->bug_id), + creator => $self->type('email', $comment->author->login), + author => $self->type('email', $comment->author->login), + time => $self->type('dateTime', $comment->creation_ts), + creation_time => $self->type('dateTime', $comment->creation_ts), + is_private => $self->type('boolean', $comment->is_private), + text => $self->type('string', $comment->body_full), + raw_text => $self->type('string', $comment->body), + 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'); + + unless (Bugzilla->user->id) { + Bugzilla->check_rate_limit("get_bug", remote_ip()); + } + Bugzilla->switch_to_shadow_db() unless Bugzilla->user->id; + + my $ids = $params->{ids}; + (defined $ids && scalar @$ids) + || ThrowCodeError('param_required', {param => 'ids'}); + + my (@bugs, @faults, @hashes); + + # Cache permissions for bugs. This highly reduces the number of calls to the DB. + # visible_bugs() is only able to handle bug IDs, so we have to skip aliases. + my @int = grep { $_ =~ /^\d+$/ } @$ids; + Bugzilla->user->visible_bugs(\@int); + + foreach my $bug_id (@$ids) { + my $bug; + if ($params->{permissive}) { + eval { $bug = Bugzilla::Bug->check($bug_id); }; + if ($@) { + push(@faults, + {id => $bug_id, faultString => $@->faultstring, faultCode => $@->faultcode,}); + undef $@; + next; + } + } + else { + $bug = Bugzilla::Bug->check($bug_id); + } + push(@bugs, $bug); + push(@hashes, $self->_bug_to_hash($bug, $params)); + } + + # 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); + + if (Bugzilla->user->id) { + foreach my $bug (@bugs) { + Bugzilla->log_user_request($bug->id, undef, 'bug-get'); + } + } + return {bugs => \@hashes, faults => \@faults}; +} + +# this is a function that gets bug activity for list of bug ids +# it can be called as the following: +# $call = $rpc->call( 'Bug.history', { ids => [1,2] }); +sub history { + my ($self, $params) = validate(@_, 'ids'); + + Bugzilla->switch_to_shadow_db(); + + my $ids = $params->{ids}; + defined $ids || ThrowCodeError('param_required', {param => 'ids'}); + + my $user = Bugzilla->user; + my $skip_private = $params->{skip_private} ? 1 : 0; + + if ($skip_private) { + + # 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; + $user->visible_bugs(\@int); + } + + my @return; + foreach my $bug_id (@$ids) { + my %item; + my $bug; + + if ($skip_private) { + $bug = Bugzilla::Bug->new({id => $bug_id, cache => 1}); + next if $bug->error || !$user->can_see_bug($bug->id); + } + else { + $bug = Bugzilla::Bug->check($bug_id); + } + + $bug_id = $bug->id; + $item{id} = $self->type('int', $bug_id); + + my ($activity) + = Bugzilla::Bug::GetBugActivity($bug_id, undef, $params->{new_since}, 1); + + my @history; + foreach my $changeset (@$activity) { + push(@history, $self->_changeset_to_hash($changeset, $params)); + } + + $item{history} = \@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 + if (Bugzilla->params->{'usebugaliases'}) { + $item{alias} = $self->type('string', $bug->alias); + } + else { + # For API reasons, we always want the value to appear, we just + # don't want it to have a value if aliases are turned off. + $item{alias} = undef; + } + + push(@return, \%item); + } + + return {bugs => \@return}; +} + +sub search { + my ($self, $params) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + + Bugzilla->switch_to_shadow_db(); + + my $match_params = dclone($params); + delete $match_params->{include_fields}; + delete $match_params->{exclude_fields}; + + # Determine whether this is a quicksearch query + if (exists $match_params->{quicksearch}) { + my $quicksearch = quicksearch($match_params->{'quicksearch'}); + my $cgi = Bugzilla::CGI->new($quicksearch); + $match_params = $cgi->Vars; + } + + if (defined($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 $match_params->{limit} && $match_params->{limit} == 0) { + if (!defined $match_params->{limit} || $match_params->{limit} > $max_results) { + $match_params->{limit} = $max_results; + } + } + else { + delete $match_params->{limit}; + delete $match_params->{offset}; + } + + # Allow to search only in bug description (initial comment) + if (defined $match_params->{description}) { + $match_params->{longdesc} = delete $match_params->{description}; + $match_params->{longdesc_initial} = 1; + } + + $match_params = Bugzilla::Bug::map_fields($match_params); + + 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 $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++; + } + + # 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 (defined $match_params->{'keywords'} + && !defined $match_params->{'keywords_type'}) + { + $match_params->{'keywords_type'} = 'allwords'; + } + + # 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 triage_owner commenter 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 + # 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'); + } + + # Allow the use of order shortcuts similar to web UI + if ($match_params->{order}) { + + # Convert the value of the "order" form field into a list of columns + # by which to sort the results. + my %order_types = ( + "Bug Number" => ["bug_id"], + "Importance" => ["priority", "bug_severity"], + "Assignee" => ["assigned_to", "bug_status", "priority", "bug_id"], + "Last Updated" => + ["changeddate", "bug_status", "priority", "assigned_to", "bug_id"], + ); + if ($order_types{$match_params->{order}}) { + $options{order} = $order_types{$match_params->{order}}; + } + else { + $options{order} = [split(/\s*,\s*/, $match_params->{order})]; + } + } + + $options{params} = $match_params; + + my $search = new Bugzilla::Search(%options); + my ($data) = $search->data; + + # BMO if the caller only wants the count, that's all we need to return + if ($params->{count_only}) { + if (Bugzilla->usage_mode == USAGE_MODE_XMLRPC) { + return $data; + } + else { + return {bug_count => $self->type('int', $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; + + # BzAPI + Bugzilla->request_cache->{bzapi_search_bugs} + = [map { $bug_objects{$_} } @bug_ids]; + + return {bugs => \@bugs}; +} + +sub possible_duplicates { + my ($self, $params) = validate(@_, 'product'); + my $user = Bugzilla->user; + + Bugzilla->switch_to_shadow_db(); + + state $params_type = Dict [ + id => Optional [Int], + product => Optional [ArrayRef [Str]], + limit => Optional [Int], + summary => Optional [Str], + include_fields => Optional [ArrayRef [Str]], + Bugzilla_api_token => Optional [Str] + ]; + + ThrowCodeError('param_invalid', + {function => 'Bug.possible_duplicates', param => 'A param'}) + if !$params_type->check($params); + + my $summary; + if ($params->{id}) { + my $bug = Bugzilla::Bug->check({id => $params->{id}, cache => 1}); + $summary = $bug->short_desc; + } + elsif ($params->{summary}) { + $summary = $params->{summary}; + } + else { + ThrowCodeError('param_required', + {function => 'Bug.possible_duplicates', param => 'id or summary'}); + } + + my @products; + foreach my $name (@{$params->{'product'} || []}) { + my $object = $user->can_enter_product($name, THROW_ERROR); + push(@products, $object); + } + + my $possible_dupes + = Bugzilla::Bug->possible_duplicates({ + summary => $summary, products => \@products, limit => $params->{limit} + }); + + # If a bug id was used, remove the bug with the same id from the list. + if ($params->{id}) { + @$possible_dupes = grep { $_->id != $params->{id} } @$possible_dupes; + } + + my @hashes = map { $self->_bug_to_hash($_, $params) } @$possible_dupes; + $self->_add_update_tokens($params, $possible_dupes, \@hashes); + return {bugs => \@hashes}; +} + +sub update { + my ($self, $params) = validate(@_, 'ids'); + + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage( + 'bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.' + ); + } + + my $user = Bugzilla->login(LOGIN_REQUIRED); + my $dbh = Bugzilla->dbh; + + # We skip certain fields because their set_ methods actually use + # the external names instead of the internal names. + $params = Bugzilla::Bug::map_fields($params, + {summary => 1, platform => 1, severity => 1, type => 1, url => 1}); + + my $ids = delete $params->{ids}; + defined $ids || ThrowCodeError('param_required', {param => 'ids'}); + + my @bugs = map { Bugzilla::Bug->check($_) } @$ids; + + my %values = %$params; + $values{other_bugs} = \@bugs; + + if (exists $values{comment} and exists $values{comment}{comment}) { + $values{comment}{body} = delete $values{comment}{comment}; + } + + # Prevent bugs that could be triggered by specifying fields that + # have valid "set_" functions in Bugzilla::Bug, but shouldn't be + # called using those field names. + delete $values{dependencies}; + + my $flags = delete $values{flags}; + + foreach my $bug (@bugs) { + if (!$user->can_edit_product($bug->product_obj->id)) { + ThrowUserError("product_edit_denied", {product => $bug->product}); + } + + $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; + $dbh->bz_start_transaction(); + foreach my $bug (@bugs) { + $all_changes{$bug->id} = $bug->update(); + } + $dbh->bz_commit_transaction(); + + foreach my $bug (@bugs) { + $bug->send_changes($all_changes{$bug->id}); + } + + my @result; + foreach my $bug (@bugs) { + my %hash = ( + id => $self->type('int', $bug->id), + last_change_time => $self->type('dateTime', $bug->delta_ts), + changes => {}, + ); + + # 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. + if (Bugzilla->params->{'usebugaliases'}) { + $hash{alias} = $self->type('string', $bug->alias); + } + else { + # For API reasons, we always want the alias field to appear, we + # just don't want it to have a value if aliases are turned off. + $hash{alias} = $self->type('string', ''); + } + + my %changes = %{$all_changes{$bug->id}}; + foreach my $field (keys %changes) { + my $change = $changes{$field}; + my $api_field = $api_field_names{$field} || $field; + + # We normalize undef to an empty string, so that the API + # stays consistent for things like Deadline that can become + # empty. + $change->[0] = '' if !defined $change->[0]; + $change->[1] = '' if !defined $change->[1]; + $hash{changes}->{$api_field} = { + removed => $self->type('string', $change->[0]), + added => $self->type('string', $change->[1]) + }; + } + + push(@result, \%hash); + } + + return {bugs => \@result}; +} + +sub create { + my ($self, $params) = @_; + my $dbh = Bugzilla->dbh; + + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage( + 'bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.' + ); + } + + Bugzilla->login(LOGIN_REQUIRED); + + # Some fields cannot be sent to Bugzilla::Bug->create + foreach my $key (qw(login password token)) { + delete $params->{$key}; + } + + $params = Bugzilla::Bug::map_fields($params); + + # Define the bug file method if missing + $params->{filed_via} //= 'api'; + + 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); + + # 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)}; +} + +sub legal_values { + my ($self, $params) = @_; + + Bugzilla->switch_to_shadow_db(); + + defined $params->{field} + or ThrowCodeError('param_required', {param => 'field'}); + + my $field = Bugzilla::Bug::FIELD_MAP->{$params->{field}} || $params->{field}; + + my @global_selects = @{Bugzilla->fields({is_select => 1, is_abnormal => 0})}; + + my $values; + if (grep($_->name eq $field, @global_selects)) { + + # The field is a valid one. + $values = get_legal_field_values($field); + } + elsif (grep($_ eq $field, PRODUCT_SPECIFIC_FIELDS)) { + my $id = $params->{product_id}; + defined $id + || ThrowCodeError('param_required', + {function => 'Bug.legal_values', param => 'product_id'}); + grep($_->id eq $id, @{Bugzilla->user->get_accessible_products}) + || ThrowUserError('product_access_denied', {id => $id}); + + my $product = new Bugzilla::Product($id); + my @objects; + if ($field eq 'version') { + @objects = @{$product->versions}; + } + elsif ($field eq 'target_milestone') { + @objects = @{$product->milestones}; + } + elsif ($field eq 'component') { + @objects = @{$product->components}; + } + + $values = [map { $_->name } @objects]; + } + else { + ThrowCodeError('invalid_field_name', {field => $params->{field}}); + } + + my @result; + foreach my $val (@$values) { + push(@result, $self->type('string', $val)); + } + + return {values => \@result}; +} + +sub add_attachment { + my ($self, $params) = validate(@_, 'ids'); + my $dbh = Bugzilla->dbh; + + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage( + 'bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.' + ); + } + + Bugzilla->login(LOGIN_REQUIRED); + defined $params->{ids} || ThrowCodeError('param_required', {param => 'ids'}); + defined $params->{data} || ThrowCodeError('param_required', {param => 'data'}); + + my @bugs = map { Bugzilla::Bug->check($_) } @{$params->{ids}}; + foreach my $bug (@bugs) { + Bugzilla->user->can_edit_product($bug->product_id) + || ThrowUserError("product_edit_denied", {product => $bug->product}); + } + + my @created; + $dbh->bz_start_transaction(); + my $timestamp = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + + my $flags = delete $params->{flags}; + my $comment = delete $params->{comment}; + my $bug_flags = delete $params->{bug_flags}; + + $comment = $comment ? trim($comment) : ''; + + foreach my $bug (@bugs) { + my $attachment = Bugzilla::Attachment->create({ + bug => $bug, + creation_ts => $timestamp, + data => $params->{data}, + description => $params->{summary}, + filename => $params->{file_name}, + mimetype => $params->{content_type}, + 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); + + # The comment has to be added even if it's empty + $bug->add_comment( + $comment, + { + isprivate => $attachment->isprivate, + type => CMT_ATTACHMENT_CREATED, + extra_data => $attachment->id + } + ); + + if ($bug_flags) { + my ($old_flags, $new_flags) = extract_flags($bug_flags, $bug); + $bug->set_flags($old_flags, $new_flags); + } + + push(@created, $attachment); + } + $_->bug->update($timestamp) foreach @created; + $dbh->bz_commit_transaction(); + + $_->send_changes() foreach @bugs; + + my %attachments + = map { $_->id => $self->_attachment_to_hash($_, $params) } @created; + + return {attachments => \%attachments}; +} + +sub update_attachment { + my ($self, $params) = validate(@_, 'ids'); + + my $user = Bugzilla->login(LOGIN_REQUIRED); + my $dbh = Bugzilla->dbh; + + my $ids = delete $params->{ids}; + defined $ids || ThrowCodeError('param_required', {param => 'ids'}); + + # Some fields cannot be sent to set_all + foreach my $key (qw(login password token)) { + delete $params->{$key}; + } + + $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}; + my $bug_flags = delete $params->{bug_flags}; + + $comment = $comment ? trim($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 targeted 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(); + my $bug = $attachment->bug; + + if ($comment) { + $bug->add_comment( + $comment, + { + isprivate => $attachment->isprivate, + type => CMT_ATTACHMENT_UPDATED, + extra_data => $attachment->id + } + ); + } + + if ($bug_flags) { + my ($old_flags, $new_flags) = extract_flags($bug_flags, $bug); + $bug->set_flags($old_flags, $new_flags); + } + + $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) = @_; + + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage( + 'bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.' + ); + } + + #The user must login in order add a comment + Bugzilla->login(LOGIN_REQUIRED); + + # Check parameters + defined $params->{id} || ThrowCodeError('param_required', {param => 'id'}); + my $comment = $params->{comment}; + (defined $comment && trim($comment) ne '') + || ThrowCodeError('param_required', {param => 'comment'}); + + my $bug = Bugzilla::Bug->check($params->{id}); + + Bugzilla->user->can_edit_product($bug->product_id) + || ThrowUserError("product_edit_denied", {product => $bug->product}); + + # Backwards-compatibility for versions before 3.6 + if (defined $params->{private}) { + $params->{is_private} = delete $params->{private}; + } + + # Append comment + $bug->add_comment( + $comment, + { + isprivate => $params->{is_private}, + work_time => $params->{work_time}, + is_markdown => (defined $params->{is_markdown} ? $params->{is_markdown} : 0) + } + ); + + # Add comment tags + $bug->set_all({comment_tags => $params->{comment_tags}}) + if defined $params->{comment_tags}; + + # Capture the call to bug->update (which creates the new comment) in + # a transaction so we're sure to get the correct comment_id. + + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + + $bug->update(); + + my $new_comment_id = $dbh->bz_last_key('longdescs', 'comment_id'); + + $dbh->bz_commit_transaction(); + + # Send mail. + Bugzilla::BugMail::Send($bug->bug_id, {changer => Bugzilla->user}); + + return {id => $self->type('int', $new_comment_id)}; +} + +sub update_see_also { + my ($self, $params) = @_; + + # BMO: Don't allow updating of bugs if disabled + if (Bugzilla->params->{disable_bug_updates}) { + ThrowErrorPage( + 'bug/process/updates-disabled.html.tmpl', + 'Bug updates are currently disabled.' + ); + } + + my $user = Bugzilla->login(LOGIN_REQUIRED); + + # Check parameters + $params->{ids} || ThrowCodeError('param_required', {param => 'id'}); + my ($add, $remove) = @$params{qw(add remove)}; + ($add || $remove) + or ThrowCodeError('params_required', {params => ['add', 'remove']}); + + my @bugs; + foreach my $id (@{$params->{ids}}) { + my $bug = Bugzilla::Bug->check($id); + $user->can_edit_product($bug->product_id) + || ThrowUserError("product_edit_denied", {product => $bug->product}); + push(@bugs, $bug); + if ($remove) { + $bug->remove_see_also($_) foreach @$remove; + } + if ($add) { + $bug->add_see_also($_) foreach @$add; + } + } + + my %changes; + foreach my $bug (@bugs) { + my $change = $bug->update(); + if (my $see_also = $change->{see_also}) { + $changes{$bug->id}->{see_also} = { + removed => [split(', ', $see_also->[0])], + added => [split(', ', $see_also->[1])], + }; + } + else { + # We still want a changes entry, for API consistency. + $changes{$bug->id}->{see_also} = {added => [], removed => []}; + } + + Bugzilla::BugMail::Send($bug->id, {changer => $user}); + } + + return {changes => \%changes}; +} + +sub attachments { + my ($self, $params) = validate(@_, 'ids', 'attachment_ids'); + + Bugzilla->switch_to_shadow_db() unless Bugzilla->user->id; + + if (!(defined $params->{ids} or defined $params->{attachment_ids})) { + ThrowCodeError('param_required', + {function => 'Bug.attachments', params => ['ids', 'attachment_ids']}); + } + + my $ids = $params->{ids} || []; + my $attach_ids = $params->{attachment_ids} || []; + + unless (Bugzilla->user->id) { + Bugzilla->check_rate_limit("get_attachments", remote_ip()); + } + + my %bugs; + foreach my $bug_id (@$ids) { + my $bug = Bugzilla::Bug->check($bug_id); + $bugs{$bug->id} = []; + foreach my $attach (@{$bug->attachments}) { + push @{$bugs{$bug->id}}, $self->_attachment_to_hash($attach, $params); + } + } + + my %attachments; + my @log_attachments; + foreach my $attach (@{Bugzilla::Attachment->new_from_list($attach_ids)}) { + Bugzilla::Bug->check($attach->bug_id); + if ($attach->isprivate && !Bugzilla->user->is_insider) { + ThrowUserError('auth_failure', + {action => 'access', object => 'attachment', attach_id => $attach->id}); + } + push @log_attachments, $attach; + + $attachments{$attach->id} = $self->_attachment_to_hash($attach, $params); + } + + if (Bugzilla->user->id) { + foreach my $attachment (@log_attachments) { + Bugzilla->log_user_request($attachment->bug_id, $attachment->id, + "attachment-get"); + } + } + + return {bugs => \%bugs, attachments => \%attachments}; +} + +sub flag_types { + 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 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 # +############################## + +# A helper for get() and search(). This is done in this fashion in order +# to produce a stable API and to explicitly type return values. +# The internals of Bugzilla::Bug are not stable enough to just +# return them directly. + +sub _bug_to_hash { + my ($self, $bug, $params) = @_; + my $user = Bugzilla->user; + + # 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 = %{filter $params, + { + alias => $self->type('string', $bug->alias), + id => $self->type('int', $bug->bug_id), + is_confirmed => $self->type('boolean', $bug->everconfirmed), + op_sys => $self->type('string', $bug->op_sys), + platform => $self->type('string', $bug->rep_platform), + priority => $self->type('string', $bug->priority), + resolution => $self->type('string', $bug->resolution), + severity => $self->type('string', $bug->bug_severity), + status => $self->type('string', $bug->bug_status), + summary => $self->type('string', $bug->short_desc), + target_milestone => $self->type('string', $bug->target_milestone), + type => $self->type('string', $bug->bug_type), + url => $self->type('string', $bug->bug_file_loc), + version => $self->type('string', $bug->version), + whiteboard => $self->type('string', $bug->status_whiteboard), + } + }; + + state $voting_enabled //= $bug->can('votes') ? 1 : 0; + if ($voting_enabled && filter_wants $params, 'votes') { + $item{votes} = $self->type('int', $bug->votes); + } + + # First we handle any fields that require extra work (such as date parsing + # or SQL calls). + if (filter_wants $params, 'assigned_to') { + $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, 'attachments', ['extra']) { + my @result; + foreach my $attachment (@{$bug->attachments}) { + next if $attachment->isprivate && !$user->is_insider; + push(@result, + $self->_attachment_to_hash($attachment, $params, ['extra'], 'attachments')); + } + $item{'attachments'} = \@result; + } + if (filter_wants $params, 'blocks') { + my @blocks = map { $self->type('int', $_) } @{$bug->blocked}; + $item{'blocks'} = \@blocks; + } + if (filter_wants $params, 'classification') { + $item{classification} = $self->type('string', $bug->classification); + } + if (filter_wants $params, 'comments', ['extra']) { + my @result; + my $comments = $bug->comments( + {order => 'oldest_to_newest', after => $params->{new_since}}); + foreach my $comment (@$comments) { + next if $comment->is_private && !$user->is_insider; + push(@result, + $self->_translate_comment($comment, $params, ['extra'], 'comments')); + } + $item{'comments'} = \@result; + } + if (filter_wants $params, 'component') { + $item{component} = $self->type('string', $bug->component); + } + if (filter_wants $params, '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('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}; + $item{'depends_on'} = \@depends_on; + } + if (filter_wants $params, 'description', ['extra']) { + my $comment = Bugzilla::Comment->match({bug_id => $bug->id, LIMIT => 1})->[0]; + $item{'description'} + = ($comment && (!$comment->is_private || Bugzilla->user->is_insider)) + ? $comment->body + : ''; + } + if (filter_wants $params, 'dupe_of') { + $item{'dupe_of'} = $self->type('int', $bug->dup_id); + } + if (filter_wants $params, 'duplicates') { + $item{'duplicates'} = [map { $self->type('int', $_->id) } @{$bug->duplicates}]; + } + if (filter_wants $params, 'filed_via', ['extra']) { + $item{'filed_via'} = $self->type('string', $bug->filed_via); + } + if (filter_wants $params, 'groups') { + my @groups = map { $self->type('string', $_->name) } @{$bug->groups_in}; + $item{'groups'} = \@groups; + } + if (filter_wants $params, 'history', ['extra']) { + my @result; + my ($activity) + = Bugzilla::Bug::GetBugActivity($bug->id, undef, $params->{new_since}, 1); + foreach my $changeset (@$activity) { + push(@result, + $self->_changeset_to_hash($changeset, $params, ['extra'], 'history')); + } + $item{'history'} = \@result; + } + if (filter_wants $params, 'is_open') { + $item{'is_open'} = $self->type('boolean', $bug->status->is_open); + } + if (filter_wants $params, 'keywords') { + my @keywords = map { $self->type('string', $_->name) } @{$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('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, 'triage_owner', ['extra']) { + my $triage_owner = $bug->component_obj->triage_owner; + $item{'triage_owner'} = $self->type('email', $triage_owner->login); + if ($triage_owner->login) { + $item{'triage_owner_detail'} + = $self->_user_to_hash($triage_owner, $params, ['extra'], 'triage_owner'); + } + } + if (filter_wants $params, 'see_also') { + my @see_also = map { $self->type('string', $_->name) } @{$bug->see_also}; + $item{'see_also'} = \@see_also; + } + if (filter_wants $params, 'flags') { + $item{'flags'} = [map { $self->_flag_to_hash($_) } @{$bug->flags}]; + } + + # Regressions + if (Bugzilla->params->{use_regression_fields}) { + if (filter_wants $params, 'regressed_by') { + my @regressed_by = map { $self->type('int', $_) } @{$bug->regressed_by}; + $item{'regressed_by'} = \@regressed_by; + } + if (filter_wants $params, 'regressions') { + my @regressions = map { $self->type('int', $_) } @{$bug->regresses}; + $item{'regressions'} = \@regressions; + } + } + + # And now custom fields + my @custom_fields = Bugzilla->active_custom_fields( + { + product => $bug->product_obj, + component => $bug->component_obj, + bug_id => $bug->id + }, + $self->wants_object, + ); + foreach my $field (@custom_fields) { + my $name = $field->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 || $field->type == FIELD_TYPE_DATE) { + my $value = $bug->$name; + $item{$name} = defined($value) ? $self->type('dateTime', $value) : undef; + } + elsif ($field->type == FIELD_TYPE_MULTI_SELECT) { + my @values = map { $self->type('string', $_) } @{$bug->$name}; + $item{$name} = \@values; + } + else { + $item{$name} = $self->type('string', $bug->$name); + } + } + + # Timetracking fields are only sent if the user can see them. + if ($user->is_timetracker) { + 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, 'deadline') { + + # 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, 'actual_time') { + $item{'actual_time'} = $self->type('double', $bug->actual_time); + } + } + + # The "accessible" bits go here because they have long names and it + # makes the code look nicer to separate them out. + 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); + } + + # BMO - support for special mentors field + if (filter_wants $params, 'mentors') { + $item{'mentors'} + = [map { $self->type('email', $_->login) } @{$bug->mentors || []}]; + $item{'mentors_detail'} + = [map { $self->_user_to_hash($_, $params, undef, 'mentors') } + @{$bug->mentors}]; + } + + if (filter_wants $params, 'comment_count') { + $item{'comment_count'} = $self->type('int', $bug->comment_count); + } + + if (filter_wants $params, 'counts', ['extra']) { + $item{'counts'} = {}; + + while (my ($key, $value) = each %{$bug->counts}) { + $item{'counts'}->{$key} = $self->type('int', $value); + } + } + + return \%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), + nick => $self->type('string', $user->nick), + name => $self->type('email', $user->login), + email => $self->type('email', $user->email), + }, + $types, $prefix; + return $item; +} + +sub _attachment_to_hash { + my ($self, $attach, $filters, $types, $prefix) = @_; + + my $item = filter $filters, + { + creation_time => $self->type('dateTime', $attach->attached), + last_change_time => $self->type('dateTime', $attach->modification_time), + id => $self->type('int', $attach->id), + 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 + # the filter wants them. + foreach my $field (qw(creator attacher)) { + if (filter_wants $filters, $field, $types, $prefix) { + $item->{$field} = $self->type('email', $attach->attacher->login); + } + } + + if (filter_wants $filters, 'creator', $types, $prefix) { + $item->{'creator_detail'} + = $self->_user_to_hash($attach->attacher, $filters, undef, 'creator'); + } + + if (filter_wants $filters, 'data', $types, $prefix) { + $item->{'data'} = $self->type('base64', $attach->data); + } + + if (filter_wants $filters, 'size', $types, $prefix) { + $item->{'size'} = $self->type('int', $attach->datasize); + } + + if (filter_wants $filters, 'flags', $types, $prefix) { + $item->{'flags'} = [map { $self->_flag_to_hash($_) } @{$attach->flags}]; + } + + return $item; +} + +sub _changeset_to_hash { + my ($self, $changeset, $filters, $types, $prefix) = @_; + + my $item = { + when => $self->type('dateTime', $changeset->{when}), + who => $self->type('email', $changeset->{who}), + changes => [] + }; + + foreach my $change (@{$changeset->{changes}}) { + my $field_name = delete $change->{fieldname}; + my $api_field_type = $api_field_types{$field_name} || 'string'; + my $api_field_name = $api_field_names{$field_name} || $field_name; + my $attach_id = delete $change->{attachid}; + my $comment = delete $change->{comment}; + + $change->{field_name} = $self->type('string', $api_field_name); + $change->{removed} = $self->type($api_field_type, $change->{removed}); + $change->{added} = $self->type($api_field_type, $change->{added}); + $change->{attachment_id} = $self->type('int', $attach_id) if $attach_id; + $change->{comment_id} = $self->type('int', $comment->id) if $comment; + $change->{comment_count} = $self->type('int', $comment->count) if $comment; + + push(@{$item->{changes}}, $change); + } + + return filter($filters, $item, $types, $prefix); +} + +sub _flag_to_hash { + my ($self, $flag) = @_; + + my $item = { + id => $self->type('int', $flag->id), + name => $self->type('string', $flag->name), + type_id => $self->type('int', $flag->type_id), + creation_date => $self->type('dateTime', $flag->creation_date), + modification_date => $self->type('dateTime', $flag->modification_date), + status => $self->type('string', $flag->status) + }; + + foreach my $field (qw(setter requestee)) { + my $field_id = $field . "_id"; + $item->{$field} = $self->type('email', $flag->$field->login) + if $flag->$field_id; + } + + return $item; +} + +sub _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 _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; diff --git a/Bugzilla/API/V1/BugUserLastVisit.pm b/Bugzilla/API/V1/BugUserLastVisit.pm new file mode 100644 index 0000000000..3153e1fd55 --- /dev/null +++ b/Bugzilla/API/V1/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::API::V1::BugUserLastVisit; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla::Bug; +use Bugzilla::Error; +use Bugzilla::API::V1::Util qw( validate filter ); +use Bugzilla::Constants; + +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}); + + next unless $user->can_see_bug($bug->id); + + $bug->update_user_last_visit($user, $last_visit_ts); + + push(@results, + $self->_bug_user_last_visit_to_hash($bug_id, $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; diff --git a/Bugzilla/API/V1/Bugzilla.pm b/Bugzilla/API/V1/Bugzilla.pm new file mode 100644 index 0000000000..6701fed76e --- /dev/null +++ b/Bugzilla/API/V1/Bugzilla.pm @@ -0,0 +1,106 @@ +# 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::API::V1::Bugzilla; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Logging; +use Bugzilla::Util qw(datetime_from); +use JSON::XS; +use Try::Tiny; + +use DateTime; + +# Basic info that is needed before logins +use constant LOGIN_EXEMPT => {timezone => 1, version => 1,}; + +sub version { + my $self = shift; + return {version => $self->type('string', BUGZILLA_VERSION)}; +} + +sub extensions { + my $self = shift; + + my %retval; + foreach my $extension (@{Bugzilla->extensions}) { + my $version = $extension->VERSION || 0; + my $name = $extension->NAME; + $retval{$name}->{version} = $self->type('string', $version); + } + return {extensions => \%retval}; +} + +sub timezone { + my $self = shift; + + # All Webservices return times in UTC; Use UTC here for backwards compat. + return {timezone => $self->type('string', "+0000")}; +} + +sub time { + my ($self) = @_; + + # All Webservices return times in UTC; Use UTC here for backwards compat. + # Hardcode values where appropriate + my $dbh = Bugzilla->dbh; + + my $db_time = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + $db_time = datetime_from($db_time, 'UTC'); + my $now_utc = DateTime->now(); + + return { + db_time => $self->type('dateTime', $db_time), + web_time => $self->type('dateTime', $now_utc), + web_time_utc => $self->type('dateTime', $now_utc), + tz_name => $self->type('string', 'UTC'), + tz_offset => $self->type('string', '+0000'), + tz_short_name => $self->type('string', 'UTC'), + }; +} + +sub jobqueue_status { + my ($self, $params) = @_; + + Bugzilla->login(LOGIN_REQUIRED); + + my $dbh = Bugzilla->dbh; + my $query = q{ + SELECT + COUNT(*) AS total, + COALESCE( + (SELECT COUNT(*) + FROM ts_error + WHERE ts_error.jobid = j.jobid + ) + , 0) AS errors + FROM ts_job j + INNER JOIN ts_funcmap f + ON f.funcid = j.funcid + GROUP BY errors + }; + + my $status; + try { + $status = $dbh->selectrow_hashref($query); + $status->{errors} = 0 + $status->{errors}; + $status->{total} = 0 + $status->{total}; + } + catch { + ERROR($_); + ThrowCodeError('jobqueue_status_error'); + }; + + return $status; +} + +1; + diff --git a/Bugzilla/API/V1/Classification.pm b/Bugzilla/API/V1/Classification.pm new file mode 100644 index 0000000000..9a58a5ed4f --- /dev/null +++ b/Bugzilla/API/V1/Classification.pm @@ -0,0 +1,82 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::API::V1::Classification; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla::Classification; +use Bugzilla::Error; +use Bugzilla::API::V1::Util qw(filter validate params_to_objects); + +sub get { + my ($self, $params) = validate(@_, 'names', 'ids'); + + defined $params->{names} + || defined $params->{ids} + || ThrowCodeError('params_required', + {function => 'Classification.get', params => ['names', 'ids']}); + + my $user = Bugzilla->user; + + Bugzilla->params->{'useclassification'} + || $user->in_group('editclassifications') + || ThrowUserError('auth_classification_not_enabled'); + + Bugzilla->switch_to_shadow_db; + + my @classification_objs + = @{params_to_objects($params, 'Bugzilla::Classification')}; + unless ($user->in_group('editclassifications')) { + my %selectable_class + = map { $_->id => 1 } @{$user->get_selectable_classifications}; + @classification_objs = grep { $selectable_class{$_->id} } @classification_objs; + } + + my @classifications + = map { $self->_classification_to_hash($_, $params) } @classification_objs; + + return {classifications => \@classifications}; +} + +sub _classification_to_hash { + 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); + + 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($_, $params) } @$products], + }; +} + +sub _product_to_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), + }, + undef, 'products'; +} + +1; diff --git a/Bugzilla/API/V1/Constants.pm b/Bugzilla/API/V1/Constants.pm new file mode 100644 index 0000000000..2af64997b3 --- /dev/null +++ b/Bugzilla/API/V1/Constants.pm @@ -0,0 +1,315 @@ +# 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::API::V1::Constants; + +use 5.10.1; +use strict; +use warnings; + +use base qw(Exporter); + +our @EXPORT = qw( + WS_ERROR_CODE + + STATUS_OK + STATUS_CREATED + STATUS_ACCEPTED + STATUS_NO_CONTENT + STATUS_MULTIPLE_CHOICES + STATUS_BAD_REQUEST + STATUS_NOT_FOUND + STATUS_GONE + REST_STATUS_CODE_MAP + + ERROR_UNKNOWN_FATAL + ERROR_UNKNOWN_TRANSIENT + + XMLRPC_CONTENT_TYPE_WHITELIST + REST_CONTENT_TYPE_WHITELIST + + API_AUTH_HEADERS +); + +# This maps the error names in global/*-error.html.tmpl to numbers. +# Generally, transient errors should have a number above 0, and +# fatal errors should have a number below 0. +# +# This hash should generally contain any error that could be thrown +# by the WebService interface. If it's extremely unlikely that the +# error could be thrown (like some CodeErrors), it doesn't have to +# be listed here. +# +# "Transient" means "If you resubmit that request with different data, +# it may work." +# +# "Fatal" means, "There's something wrong with Bugzilla, probably +# something an administrator would have to fix." +# +# NOTE: Numbers must never be recycled. If you remove a number, leave a +# comment that it was retired. Also, if an error changes its name, you'll +# have to fix it here. +use constant WS_ERROR_CODE => { + + # Generic errors (Bugzilla::Object and others) are 50-99. + object_not_specified => 50, + reassign_to_empty => 50, + param_required => 50, + params_required => 50, + undefined_field => 50, + object_does_not_exist => 51, + param_must_be_numeric => 52, + number_not_numeric => 52, + param_invalid => 53, + number_too_large => 54, + number_too_small => 55, + illegal_date => 56, + illegal_date_pronoun => 57, + + # Bug errors usually occupy the 100-200 range. + improper_bug_id_field_value => 100, + bug_id_does_not_exist => 101, + bug_access_denied => 102, + bug_access_query => 102, + + # These all mean "invalid alias" + alias_too_long => 103, + alias_in_use => 103, + alias_is_numeric => 103, + alias_has_comma_or_space => 103, + multiple_alias_not_allowed => 103, + + # Misc. bug field errors + illegal_field => 104, + freetext_too_long => 104, + + # Component errors + require_component => 105, + component_name_too_long => 105, + + # Invalid Product + no_products => 106, + entry_access_denied => 106, + product_access_denied => 106, + product_disabled => 106, + + # Invalid Summary + require_summary => 107, + + # Invalid field name + invalid_field_name => 108, + + # Not authorized to edit the bug + product_edit_denied => 109, + + # Comment-related errors + comment_is_private => 110, + comment_id_invalid => 111, + comment_too_long => 114, + 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, + + # Insidergroup Errors + user_not_insider => 113, + + # Note: 114 is above in the Comment-related section. + # Bug update errors + illegal_change => 115, + + # Dependency errors + dependency_loop_single => 116, + dependency_loop_multi => 116, + + # Note: 117 is above in the Comment-related section. + # Dup errors + dupe_loop_detected => 118, + dupe_id_required => 119, + + # Bug-related group errors + group_invalid_removal => 120, + group_restriction_not_allowed => 120, + + # Status/Resolution errors + 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, + + # Bug Type errors + bug_type_required => 135, + + # Authentication errors are usually 300-400. + invalid_username_or_password => 300, + account_disabled => 301, + auth_invalid_email => 302, + extern_id_conflict => -303, + auth_failure => 304, + password_insecure => 305, + api_key_not_valid => 306, + api_key_revoked => 306, + auth_invalid_token => 307, + invalid_cookies_or_token => 307, + + # Except, historically, AUTH_NODATA, which is 410. + login_required => 410, + + # User errors are 500-600. + account_exists => 500, + iam_username_exists => 500, + illegal_email_address => 501, + auth_cant_create_account => 501, + account_creation_disabled => 501, + account_creation_restricted => 501, + + # Error 502 password_too_short no longer exists. + # Error 503 password_too_long no longer exists. + invalid_username => 504, + iam_illegal_username => 504, + + # This is from strict_isolation, but it also basically means + # "invalid user." + invalid_user_group => 504, + user_access_by_id_denied => 505, + user_access_by_match_denied => 505, + + # Attachment errors are 600-700. + file_too_large => 600, + invalid_content_type => 601, + + # Error 602 attachment_illegal_url no longer exists. + file_not_specified => 603, + missing_attachment_description => 604, + + # Error 605 attachment_url_disabled no longer exists. + zero_length_file => 606, + + # Product erros are 700-800 + product_blank_name => 700, + product_name_too_long => 701, + product_name_already_in_use => 702, + product_name_diff_in_case => 702, + product_must_have_description => 703, + product_must_have_version => 704, + product_must_define_defaultmilestone => 705, + + # Group errors are 800-900 + empty_group_name => 800, + group_exists => 801, + empty_group_description => 802, + invalid_regexp => 803, + invalid_group_name => 804, + group_cannot_view => 805, + + # Search errors are 1000-1100 + buglist_parameters_required => 1000, + + # Job queue errors 1400-1500 + jobqueue_status_error => 1400, + + # 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, + 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. +use constant ERROR_UNKNOWN_FATAL => -32000; +use constant ERROR_UNKNOWN_TRANSIENT => 32000; + +use constant ERROR_GENERAL => 999; + +use constant XMLRPC_CONTENT_TYPE_WHITELIST => qw( + text/xml + 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 +); + +# Custom HTTP headers that can be used for API authentication rather than +# passing as URL parameters. This is useful if you do not want sensitive +# information to show up in webserver log files. +use constant API_AUTH_HEADERS => { + X_BUGZILLA_LOGIN => 'Bugzilla_login', + X_BUGZILLA_PASSWORD => 'Bugzilla_password', + X_BUGZILLA_API_KEY => 'Bugzilla_api_key', + X_BUGZILLA_TOKEN => 'Bugzilla_token', + X_PHABRICATOR_TOKEN => 'Phabricator_token', # 'whoami' only +}; + +1; diff --git a/Bugzilla/API/V1/Elastic.pm b/Bugzilla/API/V1/Elastic.pm new file mode 100644 index 0000000000..0c017af75d --- /dev/null +++ b/Bugzilla/API/V1/Elastic.pm @@ -0,0 +1,51 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# Contributor(s): Marc Schumann +# Max Kanat-Alexander +# Mads Bondo Dydensborg +# Noura Elhawary + +package Bugzilla::API::V1::Elastic; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::API::V1::Util qw(validate); +use Bugzilla::Util qw(trim detaint_natural ); + +sub suggest_users { + my ($self, $params) = @_; + + Bugzilla->switch_to_shadow_db(); + + ThrowCodeError('params_required', + {function => 'Elastic.suggest_users', params => ['match']}) + unless defined $params->{match}; + + ThrowUserError('user_access_by_match_denied') unless Bugzilla->user->id; + + my $results = Bugzilla->elastic->suggest_users($params->{match} . ""); + my @users = map { { + real_name => $self->type(string => $_->{real_name}), + name => $self->type(email => $_->{name}), + } } @$results; + + return {users => \@users}; +} + +1; diff --git a/Bugzilla/API/V1/Group.pm b/Bugzilla/API/V1/Group.pm new file mode 100644 index 0000000000..beaf908e33 --- /dev/null +++ b/Bugzilla/API/V1/Group.pm @@ -0,0 +1,219 @@ +# 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::API::V1::Group; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::API::V1::Util qw(validate translate params_to_objects); + +use constant MAPPED_RETURNS => + {userregexp => 'user_regexp', isactive => 'is_active'}; + +sub create { + my ($self, $params) = @_; + + Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->user->in_group('creategroups') + || ThrowUserError("auth_failure", + {group => "creategroups", action => "add", object => "group"}); + + # Create group + my $group = Bugzilla::Group->create({ + name => $params->{name}, + description => $params->{description}, + userregexp => $params->{user_regexp}, + isactive => $params->{is_active}, + isbuggroup => 1, + icon_url => $params->{icon_url} + }); + return {id => $self->type('int', $group->id)}; +} + +sub update { + my ($self, $params) = @_; + + my $dbh = Bugzilla->dbh; + + Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->user->in_group('creategroups') + || ThrowUserError("auth_failure", + {group => "creategroups", action => "edit", object => "group"}); + + defined($params->{names}) + || defined($params->{ids}) + || ThrowCodeError('params_required', + {function => 'Group.update', params => ['ids', 'names']}); + + my $group_objects = params_to_objects($params, 'Bugzilla::Group'); + + my %values = %$params; + + # We delete names and ids to keep only new values to set. + delete $values{names}; + delete $values{ids}; + + $dbh->bz_start_transaction(); + foreach my $group (@$group_objects) { + $group->set_all(\%values); + } + + my %changes; + foreach my $group (@$group_objects) { + my $returned_changes = $group->update(); + $changes{$group->id} = translate($returned_changes, MAPPED_RETURNS); + } + $dbh->bz_commit_transaction(); + + my @result; + foreach my $group (@$group_objects) { + my %hash = (id => $group->id, changes => {},); + foreach my $field (keys %{$changes{$group->id}}) { + my $change = $changes{$group->id}->{$field}; + $hash{changes}{$field} = { + removed => $self->type('string', $change->[0]), + added => $self->type('string', $change->[1]) + }; + } + push(@result, \%hash); + } + + 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), + nick => $self->type('string', $_->nick), + 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; diff --git a/Bugzilla/API/V1/Product.pm b/Bugzilla/API/V1/Product.pm new file mode 100644 index 0000000000..6525513bf6 --- /dev/null +++ b/Bugzilla/API/V1/Product.pm @@ -0,0 +1,285 @@ +# 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::API::V1::Product; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Error; +use Bugzilla::Constants; +use Bugzilla::API::V1::Constants; +use Bugzilla::API::V1::Util qw(validate filter filter_wants); + +use constant READ_ONLY => qw( + get + get_accessible_products + get_enterable_products + get_selectable_products +); + +use constant PUBLIC_METHODS => qw( + create + get + get_accessible_products + get_enterable_products + get_selectable_products +); + +use constant FIELD_MAP => + {has_unconfirmed => 'allows_unconfirmed', is_open => 'isactive',}; + +################################################## +# Add aliases here for method name compatibility # +################################################## + +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}]}; +} + +# 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}]}; +} + +# 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}]}; +} + +# Get a list of actual products, based on list of ids or names +our %FLAG_CACHE; + +sub get { + my ($self, $params) = validate(@_, 'ids', 'names', 'type'); + my $user = Bugzilla->user; + + Bugzilla->request_cache->{bz_etag_disable} = 1; + + defined $params->{ids} + || defined $params->{names} + || defined $params->{type} + || ThrowCodeError("params_required", + {function => "Product.get", params => ['ids', 'names', 'type']}); + + Bugzilla->switch_to_shadow_db(); + + my $products = []; + if (defined $params->{type}) { + my %product_hash; + foreach my $type (@{$params->{type}}) { + my $result = []; + if ($type eq 'accessible') { + $result = $user->get_accessible_products(); + } + elsif ($type eq 'enterable') { + $result = $user->get_enterable_products(); + } + elsif ($type eq 'selectable') { + $result = $user->get_selectable_products(); + } + else { + ThrowUserError('get_products_invalid_type', {type => $type}); + } + map { $product_hash{$_->id} = $_ } @$result; + } + $products = [values %product_hash]; + } + else { + $products = $user->get_accessible_products; + } + + my @requested_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_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 + # avoid duplicates + foreach my $product (grep { $names{lc $_->name} } @$products) { + next if grep { $_->id == $product->id } @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. + local %FLAG_CACHE = (); + my @products = map { $self->_product_to_hash($params, $_) } @requested_products; + return {products => \@products}; +} + +sub create { + my ($self, $params) = @_; + + Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->user->in_group('editcomponents') + || ThrowUserError("auth_failure", + {group => "editcomponents", action => "add", object => "products"}); + + # Create product + my $args = { + name => $params->{name}, + description => $params->{description}, + version => $params->{version}, + default_bug_type => $params->{default_bug_type}, + defaultmilestone => $params->{default_milestone}, + + # create_series has no default value. + create_series => defined $params->{create_series} + ? $params->{create_series} + : 1 + }; + foreach my $field (qw(has_unconfirmed is_open classification)) { + if (defined $params->{$field}) { + my $name = FIELD_MAP->{$field} || $field; + $args->{$name} = $params->{$field}; + } + } + my $product = Bugzilla::Product->create($args); + return {id => $self->type('int', $product->id)}; +} + +sub _product_to_hash { + my ($self, $params, $product) = @_; + + my $field_data = { + id => $self->type('int', $product->id), + name => $self->type('string', $product->name), + description => $self->type('string', $product->description), + is_active => $self->type('boolean', $product->is_active), + default_milestone => $self->type('string', $product->default_milestone), + has_unconfirmed => $self->type('boolean', $product->allows_unconfirmed), + classification => $self->type('string', $product->classification->name), + default_bug_type => $self->type('string', $product->default_bug_type), + }; + if (filter_wants($params, 'components')) { + $field_data->{components} + = [map { $self->_component_to_hash($_, $params) } @{$product->components}]; + } + if (filter_wants($params, 'versions')) { + $field_data->{versions} + = [map { $self->_version_to_hash($_, $params) } @{$product->versions}]; + } + if (filter_wants($params, 'milestones')) { + $field_data->{milestones} + = [map { $self->_milestone_to_hash($_, $params) } @{$product->milestones}]; + } + + # BMO - add default hw/os + $field_data->{default_platform} + = $self->type('string', $product->default_platform); + $field_data->{default_op_sys} = $self->type('string', $product->default_op_sys); + + # BMO - add default security group + $field_data->{default_security_group} + = $self->type('string', $product->default_security_group); + return filter($params, $field_data); +} + +sub _component_to_hash { + my ($self, $component, $params) = @_; + my $field_data = filter $params, { + id => $self->type('int', $component->id), + name => $self->type('string', $component->name), + description => $self->type('string', $component->description), + default_assigned_to => + $self->type('email', $component->default_assignee->login), + default_qa_contact => + $self->type('email', $component->default_qa_contact->login), + triage_owner => $self->type('email', $component->triage_owner->login), + sort_key => # sort_key is returned to match Bug.fields + 0, + is_active => $self->type('boolean', $component->is_active), + default_bug_type => $self->type('string', $component->default_bug_type), + }, + undef, 'components'; + + if (filter_wants($params, 'flag_types', undef, 'components')) { + $field_data->{flag_types} = { + bug => [ + map { $FLAG_CACHE{$_->id} //= $self->_flag_type_to_hash($_) } + @{$component->flag_types->{'bug'}} + ], + attachment => [ + map { $FLAG_CACHE{$_->id} //= $self->_flag_type_to_hash($_) } + @{$component->flag_types->{'attachment'}} + ], + }; + } + + return $field_data; +} + +sub _flag_type_to_hash { + my ($self, $flag_type) = @_; + return { + id => $self->type('int', $flag_type->id), + name => $self->type('string', $flag_type->name), + description => $self->type('string', $flag_type->description), + cc_list => $self->type('string', $flag_type->cc_list), + sort_key => $self->type('int', $flag_type->sortkey), + is_active => $self->type('boolean', $flag_type->is_active), + is_requestable => $self->type('boolean', $flag_type->is_requestable), + is_requesteeble => $self->type('boolean', $flag_type->is_requesteeble), + is_multiplicable => $self->type('boolean', $flag_type->is_multiplicable), + grant_group => $self->type('int', $flag_type->grant_group_id), + request_group => $self->type('int', $flag_type->request_group_id), + }; +} + +sub _version_to_hash { + my ($self, $version, $params) = @_; + return filter $params, { + id => $self->type('int', $version->id), + name => $self->type('string', $version->name), + sort_key => # sort_key is returened to match Bug.fields + 0, + is_active => $self->type('boolean', $version->is_active), + }, + undef, 'versions'; +} + +sub _milestone_to_hash { + my ($self, $milestone, $params) = @_; + return filter $params, + { + id => $self->type('int', $milestone->id), + name => $self->type('string', $milestone->name), + sort_key => $self->type('int', $milestone->sortkey), + is_active => $self->type('boolean', $milestone->is_active), + }, + undef, 'milestones'; +} + +1; diff --git a/Bugzilla/API/V1/User.pm b/Bugzilla/API/V1/User.pm index 5795dee2c8..536d466889 100644 --- a/Bugzilla/API/V1/User.pm +++ b/Bugzilla/API/V1/User.pm @@ -9,6 +9,7 @@ package Bugzilla::API::V1::User; use 5.10.1; use Mojo::Base qw( Mojolicious::Controller ); + use Mojo::JSON qw( true false ); use Bugzilla::Constants; diff --git a/Bugzilla/API/V1/Util.pm b/Bugzilla/API/V1/Util.pm new file mode 100644 index 0000000000..da21b604ae --- /dev/null +++ b/Bugzilla/API/V1/Util.pm @@ -0,0 +1,320 @@ +# 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::API::V1::Util; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla::Logging; +use Bugzilla::Flag; +use Bugzilla::FlagType; +use Bugzilla::Error; +use Bugzilla::API::V1::Constants; + +use Storable qw(dclone); +use URI::Escape qw(uri_unescape); +use Type::Params qw( compile ); +use Types::Standard -all; + +use base qw(Exporter); + +# We have to "require", not "use" this, because otherwise it tries to +# use features of Test::More during import(). +require Test::Taint if ${^TAINT}; + +our @EXPORT_OK = qw( + extract_flags + filter + filter_wants + taint_data + validate + translate + params_to_objects + fix_credentials +); + +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, $types, $prefix); + } + + return \%newhash; +} + +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 + my $cache = Bugzilla->request_cache->{filter_wants} ||= {}; + $field = "${prefix}.${field}" if $prefix; + + if (exists $cache->{$field}) { + 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 %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}; + } + 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}; + } + + 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 { + my @params = @_; + return if !@params; + + # Though this is a private function, it hasn't changed since 2004 and + # should be safe to use, and prevents us from having to write it ourselves + # or require another module to do it. + if (${^TAINT}) { + Test::Taint::_deeply_traverse(\&_delete_bad_keys, \@params); + Test::Taint::taint_deeply(\@params); + } +} + +sub _delete_bad_keys { + foreach my $item (@_) { + next if ref $item ne 'HASH'; + foreach my $key (keys %$item) { + + # 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\.\-]+$/) { + delete $item->{$key}; + } + } + } + return @_; +} + +sub validate { + my ($self, $params, @keys) = @_; + my $cache_key = join('|', (caller(1))[3], sort @keys); + + # Type->of() is the same as Type[], used here because it is easier + # to chain with plus_coercions. + state $array_of_nonrefs + = ArrayRef->of(Maybe [Value])->plus_coercions(Maybe [Value], q{ [ $_ ] },); + state $type_cache = {}; + my $params_type = $type_cache->{$cache_key} //= do { + my %fields = map { $_ => Optional [$array_of_nonrefs] } @keys; + Maybe [Dict [%fields, slurpy Any]]; + }; + + # If $params is defined but not a reference, then we weren't + # sent any parameters at all, and we're getting @keys where + # $params should be. + return ($self, undef) if (defined $params and !ref $params); + + # If @keys is not empty then we convert any named + # parameters that have scalar values to arrayrefs + # that match. + $params = $params_type->coerce($params); + if (my $type_error = $params_type->validate($params)) { + FATAL("validate() found type error: $type_error"); + ThrowUserError('invalid_params', {type_error => $type_error}) if $type_error; + } + + return ($self, $params); +} + +sub translate { + my ($params, $mapped) = @_; + my %changes; + while (my ($key, $value) = each(%$params)) { + my $new_field = $mapped->{$key} || $key; + $changes{$new_field} = $value; + } + return \%changes; +} + +sub params_to_objects { + my ($params, $class) = @_; + my (@objects, @objects_by_ids); + + @objects = map { $class->check($_) } @{$params->{names}} if $params->{names}; + + @objects_by_ids = map { $class->check({id => $_}) } @{$params->{ids}} + if $params->{ids}; + + push(@objects, @objects_by_ids); + my %seen; + @objects = grep { !$seen{$_->id}++ } @objects; + return \@objects; +} + +sub fix_credentials { + my ($params, $cgi) = @_; + + # Allow user to pass in authentication details in X-Headers + # This allows callers to keep credentials out of GET request query-strings + if ($cgi) { + foreach my $field (keys %{API_AUTH_HEADERS()}) { + next + if exists $params->{API_AUTH_HEADERS->{$field}} + || ($cgi->http($field) // '') eq ''; + $params->{API_AUTH_HEADERS->{$field}} = uri_unescape($cgi->http($field)); + } + } + + # Allow user to pass in login=foo&password=bar as a convenience + # even if not calling GET /login. We also do not delete them as + # GET /login requires "login" and "password". + if (exists $params->{'login'} && exists $params->{'password'}) { + $params->{'Bugzilla_login'} = 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}); +} + +1; diff --git a/Bugzilla/API/V1/Wants.pm b/Bugzilla/API/V1/Wants.pm new file mode 100644 index 0000000000..2fc7c9b376 --- /dev/null +++ b/Bugzilla/API/V1/Wants.pm @@ -0,0 +1,133 @@ +# 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::API::V1::Wants; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use MooX::StrictConstructor; + +use Types::Standard qw(ArrayRef Str); +use List::MoreUtils qw(any none); + +has 'cache' => (is => 'ro', required => 1); +has ['exclude_fields', 'include_fields'] => + (is => 'ro', isa => ArrayRef [Str], default => sub { return [] }); +has ['include', 'exclude'] => (is => 'lazy'); +has ['include_type', 'exclude_type'] => (is => 'lazy'); + +sub _build_include { + my ($self) = @_; + return {map { $_ => 1 } grep { not m/^_/ } @{$self->include_fields}}; +} + +sub _build_exclude { + my ($self) = @_; + return {map { $_ => 1 } grep { not m/^_/ } @{$self->exclude_fields}}; +} + +sub _build_include_type { + my ($self) = @_; + my @include = @{$self->include_fields}; + if (@include) { + return {map { substr($_, 1) => 1 } grep {m/^_/} @include}; + } + else { + return {default => 1}; + } +} + +sub _build_exclude_type { + my ($self) = @_; + return {map { substr($_, 1) => 1 } grep {m/^_/} @{$self->exclude_fields}}; +} + +sub includes { + my ($self) = @_; + return keys %{$self->include}; +} + +sub excludes { + my ($self) = @_; + return keys %{$self->exclude}; +} + +sub include_types { + my ($self) = @_; + return keys %{$self->include_type}; +} + +sub exclude_types { + my ($self) = @_; + return keys %{$self->exclude_type}; +} + +sub is_empty { + my ($self) = @_; + return @{$self->include_fields} == 0 && @{$self->exclude_fields} == 0; +} + +sub is_specific { + my ($self) = @_; + return !$self->is_empty && !$self->exclude_types && !$self->include_types; +} + +sub match { + my ($self, $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 + my $cache = $self->cache; + $field = "${prefix}.${field}" if $prefix; + + my $include = $self->include; + my $exclude = $self->exclude; + + if (exists $cache->{$field}) { + return $cache->{$field}; + } + + # Mimic old behavior if no types provided + $types //= ['default']; + $types = [$types] if $types && !ref $types; + + # Explicit inclusion/exclusion + return $cache->{$field} = 0 if $exclude->{$field}; + return $cache->{$field} = 1 if $include->{$field}; + + my $include_type = $self->include_type; + my $exclude_type = $self->exclude_type; + + # If the user has asked to include all or exclude all + return $cache->{$field} = 0 if $exclude_type->{all}; + return $cache->{$field} = 1 if $include_type->{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 (@$types) { + return $cache->{$field} = 0 if $exclude_type->{$type}; + return $cache->{$field} = 1 if $include_type->{$type}; + } + + 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 any { substr($_, 0, $len) eq $key } keys %$include; + } + + return $cache->{$field} = $wants; +} + +1; diff --git a/Bugzilla/App.pm b/Bugzilla/App.pm index 414f78f168..a8e8a47328 100644 --- a/Bugzilla/App.pm +++ b/Bugzilla/App.pm @@ -196,7 +196,7 @@ sub setup_routes { my ($self) = @_; my $r = $self->routes; - Bugzilla::App::API->setup_routes($r); + Bugzilla::App::API->setup_routes($r, $self); Bugzilla::App::BouncedEmails->setup_routes($r); Bugzilla::App::CGI->setup_routes($r); Bugzilla::App::Main->setup_routes($r); diff --git a/Bugzilla/App/API.pm b/Bugzilla/App/API.pm index a22710fa31..0ee2c657cc 100644 --- a/Bugzilla/App/API.pm +++ b/Bugzilla/App/API.pm @@ -8,16 +8,21 @@ package Bugzilla::App::API; use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + use Bugzilla::Logging; +use Bugzilla::Util qw(datetime_from); + +use MIME::Base64 qw(encode_base64); use Module::Runtime qw(require_module); -use Mojo::Base qw( Mojolicious::Controller ); +use Mojo::JSON qw(false true); use Mojo::Loader qw( find_modules ); use Try::Tiny; use constant SUPPORTED_VERSIONS => qw(V1); sub setup_routes { - my ($class, $r) = @_; + my ($class, $r, $app) = @_; # Add Bugzilla::API to namespaces for searching for controllers my $namespaces = $r->namespaces; @@ -38,6 +43,67 @@ sub setup_routes { }; } } + + # Add handler for setting JSON data type properly + $app->helper('type' => sub { _type(@_); }); +} + +sub _type { + my ($self, $type, $value) = @_; + + # This is the only type that does something special with undef. + if ($type eq 'boolean') { + return $value ? true : false; + } + + return undef if !defined $value; + + my $retval = $value; + + if ($type eq 'int') { + $retval = int($value); + } + if ($type eq 'double') { + $retval = 0.0 + $value; + } + elsif ($type eq 'string') { + + # Forces string context, so that JSON will make it a string. + $retval = "$value"; + } + elsif ($type eq 'dateTime') { + + # ISO-8601 "YYYYMMDDTHH:MM:SS" with a literal T + $retval = _datetime_format_outbound($value); + } + elsif ($type eq 'base64') { + 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; } +sub _datetime_format_outbound { + my ($date) = @_; + + return undef if (!defined $date or $date eq ''); + + my $time = $date; + if (blessed($date)) { + + # We expect this to mean we were sent a datetime object + $time->set_time_zone('UTC'); + } + else { + # We always send our time in UTC, for consistency. + # passed in value is likely a string, create a datetime object + $time = datetime_from($date, 'UTC'); + } + + return $time->iso8601(); +} 1;