diff --git a/Bugzilla/API/V1/BugAttachment.pm b/Bugzilla/API/V1/BugAttachment.pm new file mode 100644 index 0000000000..89ff9f9733 --- /dev/null +++ b/Bugzilla/API/V1/BugAttachment.pm @@ -0,0 +1,488 @@ +# 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::BugAttachment; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use List::MoreUtils qw(uniq); +use MIME::Base64 qw(decode_base64); + +use Bugzilla::API::V1::Util; +use Bugzilla::Attachment; +use Bugzilla::Bug; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Flag; +use Bugzilla::Util qw(detaint_natural remote_ip trim); +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Util qw(extract_flags filter filter_wants + merge_request_params strip_request_params translate validate); + +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', +}; + +sub setup_routes { + my ($class, $r) = @_; + my $routes + = $r->under('/' => sub { Bugzilla->usage_mode(USAGE_MODE_MOJO_REST); }); + + $routes->get('/bug/#id/attachment')->to('V1::BugAttachment#attachments'); + $routes->post('/bug/#id/attachment')->to('V1::BugAttachment#add_attachment'); + $routes->get('/bug/attachment/#attachment_id') + ->to('V1::BugAttachment#attachments'); + $routes->put('/bug/attachment/#attachment_id') + ->to('V1::BugAttachment#update_attachment'); + + $routes->options('/bug/#id/attachment') + ->to('V1::BugAttachment#options', allow => 'GET, POST'); + $routes->options('/bug/attachment/#attachment_id') + ->to('V1::BugAttachment#options', allow => 'GET, PUT'); +} + +sub options { + my ($self) = @_; + + my $allow = $self->stash('allow'); + $self->res->headers->header('Allow' => $allow); + $self->res->headers->header('Access-Control-Allow-Methods' => $allow); + + return $self->rendered(200); +} + +# Same output as type() on the legacy JSON-RPC/REST server. +sub type { + my ($self, $type, $value) = @_; + return Bugzilla::API::V1::Util->type($type, $value); +} + +# GET /bug//attachment and /bug/attachment/ +sub attachments { + my ($self) = @_; + + my $user = $self->bugzilla->login; + + my $params = {}; + for my $name (qw(ids attachment_ids include_fields exclude_fields)) { + my $values = $self->every_param($name); + $params->{$name} = $values if @$values; + } + _split_include_exclude($params); + + # As on the legacy REST server, an id in the path is added to the ones from + # the query string. + for my $pair (['id', 'ids'], ['attachment_id', 'attachment_ids']) { + my ($stash, $key) = @$pair; + next if !defined(my $id = $self->stash($stash)); + $params->{$key} = [uniq($id, @{$params->{$key} || []})]; + } + + Bugzilla->switch_to_shadow_db() unless $user->id; + + my $ids = $params->{ids} || []; + my $attach_ids = $params->{attachment_ids} || []; + + unless ($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 && !$user->is_insider) { + return $self->user_error('auth_failure', + {action => 'access', object => 'attachment', attach_id => $attach->id}); + } + push @log_attachments, $attach; + + $attachments{$attach->id} = $self->_attachment_to_hash($attach, $params); + } + + if ($user->id) { + foreach my $attachment (@log_attachments) { + Bugzilla->log_user_request($attachment->bug_id, $attachment->id, + "attachment-get"); + } + } + + return $self->render(json => {bugs => \%bugs, attachments => \%attachments}); +} + +# POST /bug//attachment +sub add_attachment { + my ($self) = @_; + + # BMO: Don't allow updating of bugs if disabled. The legacy REST server + # answered this with the HTML "updates disabled" page, as ThrowErrorPage() + # only returns an error object in JSON-RPC mode. Return the JSON error an API + # client can handle, with the generic code user_error() uses for an error + # that has no code of its own. + if (Bugzilla->params->{disable_bug_updates}) { + return $self->render( + json => { + error => 1, + code => ERROR_UNKNOWN_TRANSIENT, + message => 'Bug updates are currently disabled.', + documentation => 'https://bmo.readthedocs.io/en/latest/api/', + }, + status => REST_STATUS_CODE_MAP()->{_default} + ); + } + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + + my ($params, $error) = $self->_request_params; + return $self->user_error($error) if $error; + + defined $params->{data} + || return $self->code_error('param_required', {param => 'data'}); + $params->{data} = decode_base64($params->{data}); + + my $dbh = Bugzilla->dbh; + + my @bugs = map { Bugzilla::Bug->check($_) } @{$params->{ids}}; + foreach my $bug (@bugs) { + $user->can_edit_product($bug->product_id) + || return $self->user_error("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 $is_markdown = delete $params->{is_markdown}; + my $bug_flags = delete $params->{bug_flags}; + + $comment = $comment ? trim($comment) : ''; + + # Default to the system use_markdown setting, matching attachment.cgi. + if (!defined $is_markdown) { + $is_markdown = Bugzilla->params->{use_markdown} ? 1 : 0; + } + + 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, + is_markdown => ($is_markdown ? 1 : 0), + } + ); + + 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 $self->render(json => {attachments => \%attachments}, status => 201); +} + +# PUT /bug/attachment/ +sub update_attachment { + my ($self) = @_; + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + + my ($params, $error) = $self->_request_params; + return $self->user_error($error) if $error; + + my $dbh = Bugzilla->dbh; + my $ids = delete $params->{ids}; + + # Drop the request-level keys, which are not attachment fields, so that + # set_all() still raises unknown_method on an unrecognized field. + $params = translate(strip_request_params($params), ATTACHMENT_MAPPED_SETTERS); + + # Get all the attachments, after verifying that they exist and are editable + my @attachments = (); + my %bugs = (); + foreach my $id (@$ids) { + detaint_natural($id) + || return $self->code_error('param_must_be_numeric', + {function => 'Bug.update_attachment', param => 'ids'}); + my $attachment = Bugzilla::Attachment->new($id) + || return $self->user_error("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 $is_markdown = delete $params->{is_markdown}; + my $bug_flags = delete $params->{bug_flags}; + + $comment = $comment ? trim($comment) : ''; + + # Default to the system use_markdown setting, matching attachment.cgi. + if (!defined $is_markdown) { + $is_markdown = Bugzilla->params->{use_markdown} ? 1 : 0; + } + + # 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) { + return $self->user_error("illegal_attachment_edit", + {attach_id => $attachment->id}); + } + $attachment->set_flags(\@editable_flags, []); + } + else { + return $self->user_error("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, + is_markdown => ($is_markdown ? 1 : 0), + } + ); + } + + 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 $self->render(json => {attachments => \@result}); +} + +# Parameters of a POST or PUT: the query string and the request body, as on +# the legacy REST server. The id in the path is only used when neither of +# them has "ids". +sub _request_params { + my ($self) = @_; + + my ($params, $error) + = merge_request_params($self, [qw(ids include_fields exclude_fields)]); + return (undef, $error) if $error; + + # As legacy validate(@_, 'ids'): a single id becomes a list, and anything + # but a list of plain values (e.g. a hash of conditions) is invalid_params. + (undef, $params) = validate($self, $params, 'ids'); + $params->{ids} //= [$self->stash('id') // $self->stash('attachment_id')]; + _split_include_exclude($params); + + return ($params, undef); +} + +sub _split_include_exclude { + my ($params) = @_; + for my $name (qw(include_fields exclude_fields)) { + next if !exists $params->{$name}; + $params->{$name} = [$params->{$name}] if !ref $params->{$name}; + $params->{$name} = [map { split(/[\s,]+/) } @{$params->{$name}}]; + } +} + +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 _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; +} + +1; diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm index a0a4acfe36..07d98d4f88 100644 --- a/Bugzilla/WebService/Bug.pm +++ b/Bugzilla/WebService/Bug.pm @@ -59,40 +59,22 @@ sub DATE_FIELDS { return $fields; } -use constant BASE64_FIELDS => {add_attachment => ['data'],}; - use constant READ_ONLY => qw( - attachments get history search ); use constant PUBLIC_METHODS => qw( - add_attachment - attachments create get history possible_duplicates search update - update_attachment update_see_also ); -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()}}, @@ -705,228 +687,6 @@ sub create { return {id => $self->type('int', $bug->bug_id)}; } -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 $is_markdown = delete $params->{is_markdown}; - my $bug_flags = delete $params->{bug_flags}; - - $comment = $comment ? trim($comment) : ''; - - # Default to the system use_markdown setting, matching attachment.cgi. - if (!defined $is_markdown) { - $is_markdown = Bugzilla->params->{use_markdown} ? 1 : 0; - } - - 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, - is_markdown => ($is_markdown ? 1 : 0), - } - ); - - 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) { - detaint_natural($id) - || ThrowCodeError('param_must_be_numeric', - {function => 'Bug.update_attachment', param => '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 $is_markdown = delete $params->{is_markdown}; - my $bug_flags = delete $params->{bug_flags}; - - $comment = $comment ? trim($comment) : ''; - - # Default to the system use_markdown setting, matching attachment.cgi. - if (!defined $is_markdown) { - $is_markdown = Bugzilla->params->{use_markdown} ? 1 : 0; - } - - # 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, - is_markdown => ($is_markdown ? 1 : 0), - } - ); - } - - 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 update_see_also { my ($self, $params) = @_; @@ -980,55 +740,6 @@ sub update_see_also { 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}; -} - ############################## # Private Helper Subroutines # ############################## @@ -1473,424 +1184,188 @@ where applicable. =head1 Bug Information -=head2 attachments +=head2 get -B +B =over =item B -It allows you to get data about attachments, given a list of bugs -and/or attachment ids. +Gets information about particular bugs in the database. -B: Private attachments will only be returned if you are in the -insidergroup or if you are the submitter of the attachment. +Note: Can also be called as "get_bugs" for compatibility with Bugzilla 3.0 API. =item B -To get all current attachments for a bug: - -GET /bug//attachment - -To get a specific attachment based on attachment ID: +To get information about a particular bug using its ID or alias: -GET /bug/attachment/ +GET /bug/ The returned data format is the same as below. =item B -B: At least one of C or C is required. +In addition to the parameters below, this method also accepts the +standard L and +L arguments. =over =item C -See the description of the C parameter in the L method. +An array of numbers and strings. + +If an element in the array is entirely numeric, it represents a bug_id +from the Bugzilla database to fetch. If it contains any non-numeric +characters, it is considered to be a bug alias instead, and the bug with +that alias will be loaded. + +Note that it's possible for aliases to be disabled in Bugzilla, in which +case you will be told that you have specified an invalid bug_id if you +try to specify an alias. (It will be error 100.) -=item C +=item C B -C An array of integer attachment ids. +C Normally, if you request any inaccessible or invalid bug ids, +Bug.get will throw an error. If this parameter is True, instead of throwing an +error we return an array of hashes with a C, C and C +for each bug that fails, and return normal information for the other bugs that +were accessible. =back -Also accepts the L, -and L arguments. - =item B -A hash containing two elements: C and C. The return -value looks like this: +Two items are returned: - { - bugs => { - 1345 => [ - { (attachment) }, - { (attachment) } - ], - 9874 => [ - { (attachment) }, - { (attachment) } - ], - }, - - attachments => { - 234 => { (attachment) }, - 123 => { (attachment) }, - } - } +=over -The attachments of any bugs that you specified in the C argument in -input are returned in C on output. C is a hash that has integer -bug IDs for keys and the values are arrayrefs that contain hashes as attachments. -(Fields for attachments are described below.) +=item C -For any attachments that you specified directly in C, they -are returned in C on output. This is a hash where the attachment -ids point directly to hashes describing the individual attachment. +An array of hashes that contains information about the bugs with +the valid ids. Each hash contains the following items: -The fields for each attachment (where it says C<(attachment)> in the -diagram above) are: +These fields are returned by default or by specifying C<_default> +in C. =over -=item C +=item C -C The raw data of the attachment, encoded as Base64. +C The total number of hours that this bug has taken (so far). -=item C +If you are not in the time-tracking group, this field will not be included +in the return value. -C The length (in bytes) of the attachment. +=item C -=item C +C The unique alias of this bug. -C The time the attachment was created. +=item C -=item C +C The login name of the user to whom the bug is assigned. -C The last time the attachment was modified. +=item C -=item C +C A hash containing detailed user information for the assigned_to. To see the +keys included in the user detail hash, see below. -C The numeric id of the attachment. +=item C -=item C +C of hashes containing attachment details of the bug, in the same format +as CidE/attachment> (see the REST API documentation). -C The numeric id of the bug that the attachment is attached to. +This is an B field returned only by specifying C or +C<_extra> in C. -=item C +=item C -C The file name of the attachment. +C of Cs. The ids of bugs that are "blocked" by this bug. -=item C +=item C -C A short string describing the attachment. +C of Cs. The login names of users on the CC list of this +bug. -Also returned as C, for backwards-compatibility with older -Bugzillas. (However, this backwards-compatibility will go away in Bugzilla -5.0.) +=item C -=item C +C of hashes containing detailed user information for each of the cc list +members. To see the keys included in the user detail hash, see below. -C The MIME type of the attachment. +=item C -=item C +C The name of the current classification the bug is in. -C True if the attachment is private (only visible to a certain -group called the "insidergroup"), False otherwise. +=item C -=item C +C The number of the comments left on this bug. -C True if the attachment is obsolete, False otherwise. +=item C -=item C +C of hashes containing comment details of the bug. See the comments +section above for the object format. -C True if the attachment is a patch, False otherwise. +This is an B field returned only by specifying C or C<_extra> +in C. -=item C +Comments that are collapsed in the web UI are left out unless +C<_collapsed_comments> is also passed in C, as described for +CidE/comment> in the REST API documentation. -C The login name of the user that created the attachment. +=item C -Also returned as C, for backwards-compatibility with older -Bugzillas. (However, this backwards-compatibility will go away in Bugzilla -5.0.) +C The name of the current component of this bug. -=item C +=item C -C A hash containing detailed user information for the creator. The keys -included in the user detail hash are the same as ones returned for the Get Bug -method. +B -=item C +C A hash containing the numbers of the items in the following fields: +C, C, C, C, C, C, +C, C and C. -An array of hashes containing the information about flags currently set -for each attachment. Each flag hash contains the following items: +This is an B field returned only by specifying C or C<_extra> in +C. -=over +=item C -=item C +C When the bug was created. -C The id of the flag. +=item C -=item C +C The login name of the person who filed this bug (the reporter). -C The name of the flag. +=item C -=item C +C A hash containing detailed user information for the creator. To see the +keys included in the user detail hash, see below. -C The type id of the flag. +=item C -=item C +C The day that this bug is due to be completed, in the format +C. -C The timestamp when this flag was originally created. +If you are not in the time-tracking group, this field will not be included +in the return value. -=item C +=item C -C The timestamp when the flag was last modified. +C of Cs. The ids of bugs that this bug "depends on". -=item C +=item C -C The current status of the flag. +C The description (initial comment) of the bug. -=item C +This is an B field returned only by specifying C or +C<_extra> in C. -C The login name of the user who created or last modified the flag. +=item C -=item C +C The bug ID of the bug that this bug is a duplicate of. If this bug +isn't a duplicate of any bug, this will be null. -C The login name of the user this flag has been requested to be granted or denied. -Note, this field is only returned if a requestee is set. - -=back - -=back - -=item B - -This method can throw all the same errors as L. In addition, -it can also throw the following error: - -=over - -=item 304 (Auth Failure, Attachment is Private) - -You specified the id of a private attachment in the C -argument, and you are not in the "insider group" that can see -private attachments. - -=back - -=item B - -=over - -=item Added in Bugzilla B<3.6>. - -=item In Bugzilla B<4.0>, the C return value was renamed to -C. - -=item In Bugzilla B<4.0>, the C return value was renamed to -C. - -=item The C return value was added in Bugzilla B<4.0>. - -=item In Bugzilla B<4.2>, the C return value was removed -(this attribute no longer exists for attachments). - -=back - -=item The C array was added in Bugzilla B<4.4>. - -=item REST API call added in Bugzilla B<5.0>. - -=item The C field was added in Bugzilla B<6.0>. - -=back - - -=head2 get - -B - -=over - -=item B - -Gets information about particular bugs in the database. - -Note: Can also be called as "get_bugs" for compatibility with Bugzilla 3.0 API. - -=item B - -To get information about a particular bug using its ID or alias: - -GET /bug/ - -The returned data format is the same as below. - -=item B - -In addition to the parameters below, this method also accepts the -standard L and -L arguments. - -=over - -=item C - -An array of numbers and strings. - -If an element in the array is entirely numeric, it represents a bug_id -from the Bugzilla database to fetch. If it contains any non-numeric -characters, it is considered to be a bug alias instead, and the bug with -that alias will be loaded. - -Note that it's possible for aliases to be disabled in Bugzilla, in which -case you will be told that you have specified an invalid bug_id if you -try to specify an alias. (It will be error 100.) - -=item C B - -C Normally, if you request any inaccessible or invalid bug ids, -Bug.get will throw an error. If this parameter is True, instead of throwing an -error we return an array of hashes with a C, C and C -for each bug that fails, and return normal information for the other bugs that -were accessible. - -=back - -=item B - -Two items are returned: - -=over - -=item C - -An array of hashes that contains information about the bugs with -the valid ids. Each hash contains the following items: - -These fields are returned by default or by specifying C<_default> -in C. - -=over - -=item C - -C The total number of hours that this bug has taken (so far). - -If you are not in the time-tracking group, this field will not be included -in the return value. - -=item C - -C The unique alias of this bug. - -=item C - -C The login name of the user to whom the bug is assigned. - -=item C - -C A hash containing detailed user information for the assigned_to. To see the -keys included in the user detail hash, see below. - -=item C - -C of hashes containing attachment details of the bug. See the attachments -section above for the object format. - -This is an B field returned only by specifying C or -C<_extra> in C. - -=item C - -C of Cs. The ids of bugs that are "blocked" by this bug. - -=item C - -C of Cs. The login names of users on the CC list of this -bug. - -=item C - -C of hashes containing detailed user information for each of the cc list -members. To see the keys included in the user detail hash, see below. - -=item C - -C The name of the current classification the bug is in. - -=item C - -C The number of the comments left on this bug. - -=item C - -C of hashes containing comment details of the bug. See the comments -section above for the object format. - -This is an B field returned only by specifying C or C<_extra> -in C. - -Comments that are collapsed in the web UI are left out unless -C<_collapsed_comments> is also passed in C, as described for -CidE/comment> in the REST API documentation. - -=item C - -C The name of the current component of this bug. - -=item C - -B - -C A hash containing the numbers of the items in the following fields: -C, C, C, C, C, C, -C, C and C. - -This is an B field returned only by specifying C or C<_extra> in -C. - -=item C - -C When the bug was created. - -=item C - -C The login name of the person who filed this bug (the reporter). - -=item C - -C A hash containing detailed user information for the creator. To see the -keys included in the user detail hash, see below. - -=item C - -C The day that this bug is due to be completed, in the format -C. - -If you are not in the time-tracking group, this field will not be included -in the return value. - -=item C - -C of Cs. The ids of bugs that this bug "depends on". - -=item C - -C The description (initial comment) of the bug. - -This is an B field returned only by specifying C or -C<_extra> in C. - -=item C - -C The bug ID of the bug that this bug is a duplicate of. If this bug -isn't a duplicate of any bug, this will be null. - -=item C +=item C C of Cs. The ids of bugs that are marked as duplicate of this bug. @@ -2961,404 +2436,6 @@ loop errors had a generic code of C<32000>. =back -=head2 add_attachment - -B - -=over - -=item B - -This allows you to add an attachment to a bug in Bugzilla. - -=item B - -To create attachment on a current bug: - -POST /bug//attachment - -The params to include in the POST body, as well as the returned -data format are the same as below. The C param will be -overridden as it it pulled from the URL path. - -=item B - -=over - -=item C - -B C An array of ints and/or strings--the ids -or aliases of bugs that you want to add this attachment to. -The same attachment and comment will be added to all -these bugs. - -=item C - -B C or C The content of the attachment. -If the content of the attachment is not ASCII text, you must encode -it in base64 and declare it as the C type. - -=item C - -B C The "file name" that will be displayed -in the UI for this attachment. - -=item C - -B C A short string describing the -attachment. - -=item C - -B C The MIME type of the attachment, like -C or C. - -=item C - -C A comment to add along with this attachment. - -=item C - -C True if Bugzilla should treat this attachment as a patch. -If you specify this, you do not need to specify a C. -The C of the attachment will be forced to C. - -Defaults to False if not specified. - -=item C - -C True if the attachment should be private (restricted -to the "insidergroup"), False if the attachment should be public. - -Defaults to False if not specified. - -=item C - -C An array of hashes with flags to add to the attachment. to create a flag, -at least the status and the type_id or name must be provided. An optional requestee -can be passed if the flag type is requestable to a specific user. - -=over - -=item C - -C The name of the flag type. - -=item C - -C The internal flag type id. - -=item C - -C The flags new status (i.e. "?", "+", "-" or "X" to clear a flag). - -=item C - -C The login of the requestee if the flag type is requestable to a specific user. - -=back - -=item C - -C An optional array of hashes with flags to add to the attachment's -bug. See the C param for the L method for the object format. - -=back - -=item B - -A single item C, which contains the created -attachments in the same format as the C return -value from L. - -=item B - -This method can throw all the same errors as L, plus: - -=over - -=item 129 (Flag Status Invalid) - -The flag status is invalid. - -=item 130 (Flag Modification Denied) - -You tried to request, grant, or deny a flag but only a user with the required -permissions may make the change. - -=item 131 (Flag not Requestable from Specific Person) - -You can't ask a specific person for the flag. - -=item 133 (Flag Type not Unique) - -The flag type specified matches several flag types. You must specify -the type id value to update or add a flag. - -=item 134 (Inactive Flag Type) - -The flag type is inactive and cannot be used to create new flags. - -=item 600 (Attachment Too Large) - -You tried to attach a file that was larger than Bugzilla will accept. - -=item 601 (Invalid MIME Type) - -You specified a C argument that was blank, not a valid -MIME type, or not a MIME type that Bugzilla accepts for attachments. - -=item 603 (File Name Not Specified) - -You did not specify a valid for the C argument. - -=item 604 (Summary Required) - -You did not specify a value for the C argument. - -=item 606 (Empty Data) - -You set the "data" field to an empty string. - -=back - -=item B - -=over - -=item Added in Bugzilla B<4.0>. - -=item The C parameter was removed in Bugzilla B<4.2>. - -=back - -=item REST API call added in Bugzilla B<5.0>. - -=back - - -=head2 update_attachment - -B - -=over - -=item B - -This allows you to update attachment metadata in Bugzilla. - -=item B - -To update attachment metadata on a current attachment: - -PUT /bug/attachment/ - -The params to include in the POST body, as well as the returned -data format are the same as below. The C param will be -overridden as it it pulled from the URL path. - -=item B - -=over - -=item C - -B C An array of integers -- the ids of the attachments you -want to update. - -=item C - -C The "file name" that will be displayed -in the UI for this attachment. - -=item C - -C A short string describing the -attachment. - -=item C - -C An optional comment to add to the attachment's bug. - -=item C - -C The MIME type of the attachment, like -C or C. - -=item C - -C True if Bugzilla should treat this attachment as a patch. -If you specify this, you do not need to specify a C. -The C of the attachment will be forced to C. - -=item C - -C True if the attachment should be private (restricted -to the "insidergroup"), False if the attachment should be public. - -=item C - -C True if the attachment is obsolete, False otherwise. - -=item C - -C An array of hashes with changes to the flags. The following values -can be specified. At least the status and one of type_id, id, or name must -be specified. If a type_id or name matches a single currently set flag, -the flag will be updated unless new is specified. - -=over - -=item C - -C The name of the flag that will be created or updated. - -=item C - -C The internal flag type id that will be created or updated. You will -need to specify the C if more than one flag type of the same name exists. - -=item C - -C The flags new status (i.e. "?", "+", "-" or "X" to clear a flag). - -=item C - -C The login of the requestee if the flag type is requestable to a specific user. - -=item C - -C Use id to specify the flag to be updated. You will need to specify the C -if more than one flag is set of the same name. - -=item C - -C Set to true if you specifically want a new flag to be created. - -=back - -=item C - -C An optional array of hashes with changes to the flags of the attachment's -bug. See the C param for the L method for the object format. - -=item B - -A C with a single field, "attachment". This points to an array of hashes -with the following fields: - -=over - -=item C - -C The id of the attachment that was updated. - -=item C - -C The exact time that this update was done at, for this attachment. -If no update was done (that is, no fields had their values changed and -no comment was added) then this will instead be the last time the attachment -was updated. - -=item C - -C The changes that were actually done on this bug. The keys are -the names of the fields that were changed, and the values are a hash -with two keys: - -=over - -=item C (C) The values that were added to this field. -possibly a comma-and-space-separated list if multiple values were added. - -=item C (C) The values that were removed from this -field. - -=back - -=back - -Here's an example of what a return value might look like: - - { - attachments => [ - { - id => 123, - last_change_time => '2010-01-01T12:34:56', - changes => { - summary => { - removed => 'Sample ptach', - added => 'Sample patch' - }, - is_obsolete => { - removed => '0', - added => '1', - } - }, - } - ] - } - -=item B - -This method can throw all the same errors as L, plus: - -=over - -=item 129 (Flag Status Invalid) - -The flag status is invalid. - -=item 130 (Flag Modification Denied) - -You tried to request, grant, or deny a flag but only a user with the required -permissions may make the change. - -=item 131 (Flag not Requestable from Specific Person) - -You can't ask a specific person for the flag. - -=item 132 (Flag not Unique) - -The flag specified has been set multiple times. You must specify the id -value to update the flag. - -=item 133 (Flag Type not Unique) - -The flag type specified matches several flag types. You must specify -the type id value to update or add a flag. - -=item 134 (Inactive Flag Type) - -The flag type is inactive and cannot be used to create new flags. - -=item 601 (Invalid MIME Type) - -You specified a C argument that was blank, not a valid -MIME type, or not a MIME type that Bugzilla accepts for attachments. - -=item 603 (File Name Not Specified) - -You did not specify a valid for the C argument. - -=item 604 (Summary Required) - -You did not specify a value for the C argument. - -=back - -=item B - -=over - -=item Added in Bugzilla B<5.0>. - -=back - -=back - -=back - =head2 update B diff --git a/Bugzilla/WebService/Server/REST/Resources/Bug.pm b/Bugzilla/WebService/Server/REST/Resources/Bug.pm index 372c62a095..69747d34c0 100644 --- a/Bugzilla/WebService/Server/REST/Resources/Bug.pm +++ b/Bugzilla/WebService/Server/REST/Resources/Bug.pm @@ -53,37 +53,6 @@ sub _rest_resources { }, } }, - qr{^/bug/([^/]+)/attachment$}, - { - GET => { - method => 'attachments', - params => sub { - return {ids => [$_[0]]}; - } - }, - POST => { - method => 'add_attachment', - params => sub { - return {ids => [$_[0]]}; - }, - success_code => STATUS_CREATED - } - }, - qr{^/bug/attachment/([^/]+)$}, - { - GET => { - method => 'attachments', - params => sub { - return {attachment_ids => [$_[0]]}; - } - }, - PUT => { - method => 'update_attachment', - params => sub { - return {ids => [$_[0]]}; - } - } - } ]; return $rest_resources; } diff --git a/qa/t/rest_bug_update_attachment.t b/qa/t/rest_bug_update_attachment.t new file mode 100644 index 0000000000..3789aa78c1 --- /dev/null +++ b/qa/t/rest_bug_update_attachment.t @@ -0,0 +1,113 @@ +#!/usr/bin/env perl +# 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. + +##################################################### +# Test for REST call to Bug.update_attachment() # +# PUT /rest/bug/attachment/ # +##################################################### + +use 5.10.1; +use strict; +use warnings; +use lib qw(lib ../../lib ../../local/lib/perl5); + +use Bugzilla; +use MIME::Base64 qw(encode_base64); +use QA::Util qw(get_config); +use QA::REST::Util qw(api_headers); + +use Test::Mojo; +use Test::More; + +my $config = get_config(); +my $url = Bugzilla->localconfig->urlbase; +my $admin_api_key = $config->{admin_user_api_key}; +my $headers = api_headers($admin_api_key); + +my $t = Test::Mojo->new(); +$t->ua->max_redirects(1); + +sub new_attachment { + my ($summary) = @_; + $t->post_ok( + $url + . 'rest/bug/public_bug/attachment' => $headers => json => { + summary => $summary, + file_name => 'update.txt', + content_type => 'text/plain', + data => encode_base64('update test', ''), + } + )->status_is(201); + my ($id) = keys %{$t->tx->res->json->{attachments}}; + return $id; +} + +my $first = new_attachment('first attachment'); +my $second = new_attachment('second attachment'); + +# The id in the path is used when the body has no ids. +$t->put_ok($url + . "rest/bug/attachment/$first" => $headers => json => + {summary => 'first renamed', is_obsolete => 1, comment => 'obsoleted'}) + ->status_is(200) + ->json_is('/attachments/0/id' => $first) + ->json_is('/attachments/0/changes/summary/removed' => 'first attachment') + ->json_is('/attachments/0/changes/summary/added' => 'first renamed') + ->json_is('/attachments/0/changes/is_obsolete/added' => '1') + ->json_like('/attachments/0/last_change_time' => qr/^\d{4}-\d\d-\d\dT.*Z$/); + +# ids in the body win over the id in the path, as on the legacy REST server. +$t->put_ok($url + . "rest/bug/attachment/$first" => $headers => json => + {ids => [$second], summary => 'second renamed'}) + ->status_is(200) + ->json_is('/attachments/0/id' => $second); +is(scalar @{$t->tx->res->json->{attachments}}, 1, + 'Only the body id is updated'); + +# Credentials in the query string are not taken for attachment fields. +$t->put_ok($url + . "rest/bug/attachment/$second?api_key=$admin_api_key" => {} => json => + {summary => 'renamed with api_key'}) + ->status_is(200) + ->json_is('/attachments/0/changes/summary/added' => 'renamed with api_key'); + +# Both attachments can be fetched together: the path id is added to the query. +$t->get_ok( + $url . "rest/bug/attachment/$first?attachment_ids=$second" => $headers) + ->status_is(200) + ->json_is("/attachments/$first/summary" => 'first renamed') + ->json_is("/attachments/$first/is_obsolete" => 1) + ->json_is("/attachments/$second/summary" => 'renamed with api_key'); + +# Errors +$t->put_ok($url + . "rest/bug/attachment/$first" => api_headers(undef) => json => + {summary => 'logged out'}) + ->status_isnt(200) + ->json_like('/message' => qr/You must log in/); + +$t->put_ok( + $url . "rest/bug/attachment/$first" => $headers => json => {bogus => 1}) + ->status_isnt(200) + ->json_like('/message' => qr/set_bogus/); + +$t->put_ok($url + . 'rest/bug/attachment/999999999' => $headers => json => + {summary => 'no such attachment'}) + ->status_isnt(200) + ->json_like('/message' => qr/999999999/); + +$t->options_ok($url . "rest/bug/attachment/$first") + ->status_is(200) + ->header_is('Allow' => 'GET, PUT'); +$t->options_ok($url . 'rest/bug/public_bug/attachment') + ->status_is(200) + ->header_is('Allow' => 'GET, POST'); + +done_testing(); diff --git a/t/app-cgi-request-limit.t b/t/app-cgi-request-limit.t index 0c55f864c3..ed95346b43 100644 --- a/t/app-cgi-request-limit.t +++ b/t/app-cgi-request-limit.t @@ -159,7 +159,7 @@ $t->post_ok( ->content_like(qr{The request is too large\.}); $t->post_ok( - '/rest/bug/1/attachment' => { + '/rest/bug' => { 'Content-Length' => length($body), 'Content-Type' => "multipart/form-data; boundary=$boundary", } => $body