From e6ce42da965223f289aee0454517fe94c7192c7a Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Tue, 6 Oct 2026 21:14:34 +0200 Subject: [PATCH 1/9] Bug 2078690 - Migrate User REST resource to native Mojo API: user objects (get, create, update, suggest, whoami) --- Bugzilla/API/V1/UserObject.pm | 639 +++++++++++++++++++++++++++++++ docs/en/rst/api/core/v1/user.rst | 5 +- qa/t/rest_user_get.t | 19 + qa/t/rest_user_suggest_whoami.t | 95 +++++ 4 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 Bugzilla/API/V1/UserObject.pm create mode 100644 qa/t/rest_user_suggest_whoami.t diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm new file mode 100644 index 0000000000..5da28c0b0e --- /dev/null +++ b/Bugzilla/API/V1/UserObject.pm @@ -0,0 +1,639 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::API::V1::UserObject; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Mojo::JSON qw(true false); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Group; +use Bugzilla::Hook; +use Bugzilla::Logging; +use Bugzilla::User; +use Bugzilla::Util + qw(datetime_from detaint_natural email_filter mojo_user_agent trim); +use Bugzilla::WebService::Util + qw(filter filter_wants merge_request_params params_to_objects translate); + +use Digest::HMAC_SHA1 qw(hmac_sha1_hex); +use Try::Tiny; + +use constant MAPPED_FIELDS => + {email => 'login', full_name => 'name', login_denied_text => 'disabledtext',}; + +use constant MAPPED_RETURNS => { + login_name => 'email', + realname => 'full_name', + disabledtext => 'login_denied_text', +}; + +sub setup_routes { + my ($class, $r) = @_; + my $routes + = $r->under('/' => sub { Bugzilla->usage_mode(USAGE_MODE_MOJO_REST); }); + + $routes->get('/whoami')->to('V1::UserObject#whoami'); + $routes->options('/whoami')->to('V1::UserObject#options', allow => 'GET'); + + # Must come before /user/#id_or_name, which would otherwise match it. + $routes->get('/user/suggest')->to('V1::UserObject#suggest'); + $routes->options('/user/suggest')->to('V1::UserObject#options', allow => 'GET'); + + $routes->get('/user')->to('V1::UserObject#get'); + $routes->post('/user')->to('V1::UserObject#create'); + $routes->get('/user/#id_or_name')->to('V1::UserObject#get'); + $routes->put('/user/#id_or_name')->to('V1::UserObject#update'); + + $routes->options('/user')->to('V1::UserObject#options', allow => 'GET, POST'); + $routes->options('/user/#id_or_name') + ->to('V1::UserObject#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); +} + +# The webservice_user_get hook hands this controller to extensions as +# "webservice", and Review, UserProfile and TagNewUsers call ->type on it, as +# they did on the legacy JSON-RPC/REST server. Same output as the legacy one. +sub type { + my ($self, $type, $value) = @_; + + # This is the only type that does something special with undef. + return $value ? true : false if $type eq 'boolean'; + + return undef if !defined $value; + return int($value) if $type eq 'int'; + return "$value" if $type eq 'string'; + return email_filter($value) + if $type eq 'email' && Bugzilla->params->{webservice_email_filter}; + + # Always UTC, with the timezone specifier. + return $value ? datetime_from($value, 'UTC')->iso8601() . 'Z' : '' + if $type eq 'dateTime'; + + return $value; +} + +sub create { + my ($self) = @_; + + # A logged-out user is in no group, so gets the same auth_failure as legacy. + my $user = $self->bugzilla->login; + $user->in_group('editusers') + || return $self->user_error('auth_failure', + {group => 'editusers', action => 'add', object => 'users'}); + + my ($params, $error) = merge_request_params($self); + return $self->user_error($error) if $error; + + my $email = trim($params->{email}) + || return $self->code_error('param_required', {param => 'email'}); + + my $new_user = Bugzilla::User->create({ + login_name => $email, + realname => trim($params->{full_name}), + cryptpassword => trim($params->{password}) || '*', + }); + + return $self->render( + json => {id => $self->type('int', $new_user->id)}, + status => 201 + ); +} + +sub suggest { + my ($self) = @_; + + my $user = $self->bugzilla->login; + + my ($params, $error) = $self->_request_params; + return $self->user_error($error) if $error; + + Bugzilla->switch_to_shadow_db(); + + defined $params->{match} + || return $self->code_error('params_required', + {function => 'User.suggest', params => ['match']}); + + $user->id || return $self->user_error('user_access_by_match_denied'); + + # Not trimmed, as in the legacy method. + my $s = $params->{match}; + return $self->render(json => {users => []}) if length($s) < 3; + + my $dbh = Bugzilla->dbh; + my @select = ('userid AS id'); + my $order = 'last_activity_ts DESC'; + my $where; + state $have_mysql = $dbh->isa('Bugzilla::DB::Mysql'); + + if ($s =~ /^[:@](.+)$/s) { + $where = $dbh->sql_prefix_match(nickname => $1); + } + elsif ($s =~ /@/) { + $where = $dbh->sql_prefix_match(login_name => $s); + } + else { + if ($have_mysql && ($s =~ /[[:space:]]/ || $s =~ /[^[:ascii:]]/)) { + my $match = $dbh->sql_prefix_match_fulltext('realname', $s); + push @select, "$match AS relevance"; + $order = 'relevance DESC'; + $where = $match; + } + elsif ($have_mysql && $s =~ /^[[:upper:]]/) { + my $match = $dbh->sql_prefix_match_fulltext('realname', $s); + $where = join ' OR ', $match, $dbh->sql_prefix_match(nickname => $s), + $dbh->sql_prefix_match(login_name => $s); + } + else { + $where = join ' OR ', $dbh->sql_prefix_match(nickname => $s), + $dbh->sql_prefix_match(login_name => $s); + } + } + $where = "($where) AND is_enabled = 1"; + + my $results = $dbh->selectall_arrayref( + "SELECT " + . join(', ', @select) + . " FROM profiles WHERE $where ORDER BY $order LIMIT 25", + {Slice => {}} + ); + my $user_objects = Bugzilla::User->new_from_list([map { $_->{id} } @$results]); + + my @user_data = map { { + id => $self->type('int', $_->id), + real_name => $self->type('string', $_->name), + nick => $self->type('string', $_->nick), + name => $self->type('email', $_->login), + } } @$user_objects; + + Bugzilla::Hook::process( + 'webservice_user_get', + { + webservice => $self, + params => $params, + user_data => \@user_data, + user_objects => $user_objects + } + ); + + return $self->render(json => {users => \@user_data}); +} + +# Return user information by passing either user ids or login names or both +# together. +sub get { + my ($self) = @_; + + my $api_user = $self->bugzilla->login; + + my ($params, $error) + = $self->_request_params(qw(names ids match group_ids groups ldap_emails)); + return $self->user_error($error) if $error; + + if (defined(my $id_or_name = $self->stash('id_or_name'))) { + $params->{$id_or_name =~ /^\d+$/ ? 'ids' : 'names'} = [$id_or_name]; + } + + Bugzilla->switch_to_shadow_db(); + + defined($params->{names}) + || defined($params->{ids}) + || defined($params->{match}) + || defined($params->{ldap_emails}) + || return $self->code_error('params_required', + {function => 'User.get', params => ['ids', 'names', 'match']}); + + my (@user_objects, @faults); + if ($params->{names}) { + foreach my $name (@{$params->{names}}) { + + # If permissive mode, then we do not kill the whole + # request if there is an error with user lookup. + # We store the errors in 'faults' array. + if ($params->{permissive}) { + + # In ERROR_MODE_DIE the error is thrown as its plain text message. + my $old_error_mode = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + my $user_obj = eval { Bugzilla::User->check({name => $name}) }; + my $message = $@; + Bugzilla->error_mode($old_error_mode); + if (!$user_obj) { + push @faults, {name => $name, error => true, message => trim("$message")}; + next; + } + push @user_objects, $user_obj; + } + else { + push @user_objects, Bugzilla::User->check({name => $name}); + } + } + } + + # Allow users in mozilla-employee-confidential to search by ldap_email + if ( $api_user->in_group('mozilla-employee-confidential') + && $params->{ldap_emails}) + { + foreach my $email (@{$params->{ldap_emails}}) { + + # There could be more than one match per ldap email if the user has multiple accounts + my $user_ids + = Bugzilla->dbh->selectcol_arrayref( + 'SELECT user_id FROM profile_mfa WHERE name = \'user\' AND value = ?', + undef, $email); + next if !@{$user_ids}; + foreach my $user_id (@{$user_ids}) { + my $user_obj = Bugzilla::User->new($user_id); + next if $user_obj->mfa ne 'Duo'; + push @user_objects, $user_obj; + } + } + } + elsif ($params->{ldap_emails}) { + return $self->user_error('user_access_by_ldap_denied'); + } + + # start filtering to remove duplicate user ids + my %unique_users = map { $_->id => $_ } @user_objects; + @user_objects = values %unique_users; + + my @users; + + # If the user is not logged in: Return an error if they passed any user ids. + # Otherwise, return a limited amount of information based on login names. + if (!$api_user->id) { + if ($params->{ids}) { + return $self->user_error('user_access_by_id_denied'); + } + if ($params->{match}) { + return $self->user_error('user_access_by_match_denied'); + } + my $in_group = _filter_users_by_group($api_user, \@user_objects, $params); + @users = map { + filter $params, + { + id => $self->type('int', $_->id), + real_name => $self->type('string', $_->name), + nick => $self->type('string', $_->nick), + name => $self->type('email', $_->login), + } + } @$in_group; + + return $self->render(json => {users => \@users, faults => \@faults}); + } + + my $obj_by_ids; + $obj_by_ids = Bugzilla::User->new_from_list($params->{ids}) if $params->{ids}; + + # obj_by_ids are only visible to the user if they can see + # the otheruser, for non visible otheruser throw an error + foreach my $obj (@$obj_by_ids) { + if ($api_user->can_see_user($obj)) { + if (!$unique_users{$obj->id}) { + push(@user_objects, $obj); + $unique_users{$obj->id} = $obj; + } + } + else { + return $self->user_error( + 'auth_failure', + { + reason => 'not_visible', + action => 'access', + object => 'user', + userid => $obj->id + } + ); + } + } + + # User Matching + my $limit; + if ($params->{limit}) { + detaint_natural($params->{limit}) + || return $self->code_error('param_must_be_numeric', + {function => 'User.match', param => 'limit'}); + $limit = $params->{limit}; + } + my $exclude_disabled = $params->{'include_disabled'} ? 0 : 1; + foreach my $match_string (@{$params->{'match'} || []}) { + my $matched = Bugzilla::User::match($match_string, $limit, $exclude_disabled); + foreach my $user_obj (@$matched) { + if (!$unique_users{$user_obj->id}) { + push @user_objects, $user_obj; + $unique_users{$user_obj->id} = $user_obj; + } + } + } + + my $in_group = _filter_users_by_group($api_user, \@user_objects, $params); + foreach my $user_obj (@$in_group) { + my $user_info = filter $params, + { + id => $self->type('int', $user_obj->id), + real_name => $self->type('string', $user_obj->name), + nick => $self->type('string', $user_obj->nick), + name => $self->type('email', $user_obj->login), + email => $self->type('email', $user_obj->email), + can_login => $self->type('boolean', $user_obj->is_enabled ? 1 : 0), + last_seen_date => $self->type('dateTime', $user_obj->last_seen_date), + creation_time => $self->type('dateTime', $user_obj->creation_ts), + }; + + if ($api_user->in_group('disableusers')) { + if (filter_wants($params, 'email_enabled')) { + $user_info->{email_enabled} = $self->type('boolean', $user_obj->email_enabled); + } + if (filter_wants($params, 'login_denied_text')) { + $user_info->{login_denied_text} + = $self->type('string', $user_obj->disabledtext); + } + } + + if ($api_user->id == $user_obj->id) { + if (filter_wants($params, 'saved_searches')) { + $user_info->{saved_searches} + = [map { $self->_query_to_hash($_) } @{$user_obj->queries}]; + } + } + + # If calling user is member of mozilla-employee-confidential, + # return ldap_email value as well + if ( $api_user->in_group('mozilla-employee-confidential') + && $user_obj->ldap_email) + { + $user_info->{ldap_email} = $user_obj->ldap_email; + } + + if (filter_wants($params, 'groups')) { + if ( $api_user->id == $user_obj->id + || $api_user->in_group('mozilla-employee-confidential')) + { + $user_info->{groups} = [map { $self->_group_to_hash($_) } @{$user_obj->groups}]; + } + else { + $user_info->{groups} + = [map { $self->_group_to_hash($_) } + grep { $api_user->in_group('editusers') || $api_user->can_bless($_->id) } + @{$user_obj->groups}]; + } + } + + push(@users, $user_info); + } + + Bugzilla::Hook::process( + 'webservice_user_get', + { + webservice => $self, + params => $params, + user_data => \@users, + user_objects => $in_group, + } + ); + + return $self->render(json => {users => \@users, faults => \@faults}); +} + +sub update { + my ($self) = @_; + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + + # Reject access if there is no sense in continuing. + $user->in_group('editusers') + || return $self->user_error('auth_failure', + {group => 'editusers', action => 'edit', object => 'users'}); + + my ($params, $error) = $self->_request_params(qw(names ids)); + return $self->user_error($error) if $error; + + if (defined(my $id_or_name = $self->stash('id_or_name'))) { + $params + = $id_or_name =~ /^\d+$/ + ? {%$params, ids => [$id_or_name], names => undef} + : {%$params, names => [$id_or_name], ids => undef}; + } + + defined($params->{names}) + || defined($params->{ids}) + || return $self->code_error('params_required', + {function => 'User.update', params => ['ids', 'names']}); + + my $user_objects = params_to_objects($params, 'Bugzilla::User'); + + # Some accounts are protected from being edited by non-admins. + foreach my $user_obj (@$user_objects) { + $user_obj->check_can_be_edited(); + } + + # Drop the request-level keys that are not user fields and pass everything + # else through, so set_all() still raises unknown_method on an unrecognized + # field rather than silently ignoring it. + my $values = translate($params, MAPPED_FIELDS); + delete @$values{qw(ids names include_fields exclude_fields + Bugzilla_api_key Bugzilla_api_token Bugzilla_login Bugzilla_password)}; + + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + foreach my $user_obj (@$user_objects) { + $user_obj->set_all($values); + } + + my %changes; + foreach my $user_obj (@$user_objects) { + my $returned_changes = $user_obj->update(); + $changes{$user_obj->id} = translate($returned_changes, MAPPED_RETURNS); + } + $dbh->bz_commit_transaction(); + + my @result; + foreach my $user_obj (@$user_objects) { + my %hash = (id => $self->type('int', $user_obj->id), changes => {},); + + foreach my $field (keys %{$changes{$user_obj->id}}) { + my $change = $changes{$user_obj->id}->{$field}; + + # We normalize undef to an empty string, so that the API + # stays consistent for things that can become empty. + $change->[0] = '' if !defined $change->[0]; + $change->[1] = '' if !defined $change->[1]; + + # We also flatten arrays (used by groups and blessed_groups) + $change->[0] = join(',', @{$change->[0]}) if ref $change->[0]; + $change->[1] = join(',', @{$change->[1]}) if ref $change->[1]; + + $hash{changes}{$field} = { + removed => $self->type('string', $change->[0]), + added => $self->type('string', $change->[1]) + }; + } + + push(@result, \%hash); + } + + return $self->render(json => {users => \@result}); +} + +sub whoami { + my ($self) = @_; + + my $user = $self->_user_from_phab_token; + if (!$user) { + $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; + + # Generate a deterministic ID from the site-wide-secret and user-id. + # This can be used for user tracking in other systems without the + # ability to trace the ID back to a specific Bugzilla account. + my $uuid = hmac_sha1_hex($user->id, Bugzilla->localconfig->site_wide_secret); + + return $self->render( + json => filter( + $params, + { + 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), + mfa_status => $self->type('boolean', !!$user->mfa), + groups => [map { $_->name } @{$user->groups}], + uuid => $self->type('string', 'bmo-who:' . $uuid), + } + ) + ); +} + +# Merged query-string and body params, with the given params plus +# include_fields/exclude_fields always returned as lists. +sub _request_params { + my ($self, @list_params) = @_; + push @list_params, qw(include_fields exclude_fields); + + my ($params, $error) = merge_request_params($self, \@list_params); + return (undef, $error) if $error; + + # A JSON body is merged in as-is, so a single value there is not yet a list; + # legacy validate() coerced it the same way. + for my $field (@list_params) { + $params->{$field} = [$params->{$field}] + if defined $params->{$field} && !ref $params->{$field}; + } + + for my $field (qw(include_fields exclude_fields)) { + $params->{$field} = [map { split(/[\s,]+/) } @{$params->{$field}}] + if exists $params->{$field}; + } + + return ($params, undef); +} + +sub _user_from_phab_token { + my ($self) = @_; + + # BMO - If a token is provided in the X-PHABRICATOR-TOKEN header, we use that + # to request the associated email address from Phabricator via its + # `user.whoami` endpoint. + + # only if PhabBugz is configure and X-PHABRICATOR-TOKEN is provided + (my $phab_url = Bugzilla->params->{phabricator_base_uri}) =~ s{/$}{}; + my $phab_token = $self->req->headers->header('X-Phabricator-Token'); + return undef unless $phab_url && $phab_token; + + return try { + + # query phabricator's whoami endpoint + my $ua = mojo_user_agent({request_timeout => 5}); + $ua->transactor->name('BMO user.whoami shim'); + my $res = $ua->get( + "$phab_url/api/user.whoami" => form => {'api.token' => $phab_token}); + my $ph_whoami = $res->result->json; + + # treat any phabricator generated error as an invalid api-key + if (my $error = $ph_whoami->{error_info}) { + DEBUG("Phabricator user.whoami failed: $error"); + ThrowUserError('api_key_not_valid'); + } + + # load user from primaryEmail + my $user = Bugzilla::User->new( + {name => $ph_whoami->{result}->{primaryEmail}, cache => 1}); + if (!$user) { + DEBUG("No Bugzilla user for Phabricator email: " + . $ph_whoami->{result}->{primaryEmail}); + ThrowUserError('api_key_not_valid'); + } + $user; + } + catch { + WARN("Request to $phab_url failed: $_"); + ThrowUserError('api_key_not_valid'); + }; +} + +sub _filter_users_by_group { + my ($api_user, $users, $params) = @_; + my ($group_ids, $group_names) = @$params{qw(group_ids groups)}; + + # If no groups are specified, we return all users. + return $users if (!$group_ids and !$group_names); + + my @groups = map { Bugzilla::Group->check({id => $_}) } @{$group_ids || []}; + + if ($group_names) { + foreach my $name (@$group_names) { + my $group + = Bugzilla::Group->check({name => $name, _error => 'invalid_group_name'}); + $api_user->in_group($group) + || ThrowUserError('invalid_group_name', {name => $name}); + push(@groups, $group); + } + } + + my @in_group = grep { + my $user = $_; + grep { $user->in_group($_) } @groups + } @$users; + return \@in_group; +} + +sub _group_to_hash { + my ($self, $group) = @_; + return { + id => $self->type('int', $group->id), + name => $self->type('string', $group->name), + description => $self->type('string', $group->description), + }; +} + +sub _query_to_hash { + my ($self, $query) = @_; + return { + id => $self->type('int', $query->id), + name => $self->type('string', $query->name), + url => $self->type('string', $query->url), + }; +} + +1; diff --git a/docs/en/rst/api/core/v1/user.rst b/docs/en/rst/api/core/v1/user.rst index 9091e2739e..0d42130b9e 100644 --- a/docs/en/rst/api/core/v1/user.rst +++ b/docs/en/rst/api/core/v1/user.rst @@ -434,9 +434,8 @@ querying your own account, even if you are in the editusers group. Who Am I -------- -Allows for validating a user's API key, token, or username and password. -If successfully authenticated, it returns simple information about the -logged in user. +Allows for validating a user's API key. If successfully authenticated, it +returns simple information about the logged in user. **Request** diff --git a/qa/t/rest_user_get.t b/qa/t/rest_user_get.t index 4fcc9be119..a7334aba23 100644 --- a/qa/t/rest_user_get.t +++ b/qa/t/rest_user_get.t @@ -229,4 +229,23 @@ $t->get_ok(rest_get_url($url, 'rest/user', is(scalar keys %{$t->tx->res->json->{users}[0]}, 1, 'Only one field returned'); ok(exists $t->tx->res->json->{users}[0]{name}, '...and that field is the "name" field'); +#################### +# Permissive Tests # +#################### + +# Without permissive, one unknown name fails the whole request. +$t->get_ok(rest_get_url($url, 'rest/user', + {names => [$get_user, 'no-such-user@mozilla.test']}) => $anon)->status_isnt(200); + +# With permissive, the unknown name is reported in faults instead. +$t->get_ok(rest_get_url($url, 'rest/user', + {names => [$get_user, 'no-such-user@mozilla.test'], permissive => 1}) => $anon) + ->status_is(200) + ->json_is('/users/0/name' => $get_user) + ->json_is('/faults/0/name' => 'no-such-user@mozilla.test') + ->json_is('/faults/0/error' => Mojo::JSON->true) + ->json_like('/faults/0/message' => qr/no-such-user\@mozilla\.test/); +is(scalar @{$t->tx->res->json->{users}}, 1, 'permissive: one user returned'); +is(scalar @{$t->tx->res->json->{faults}}, 1, 'permissive: one fault returned'); + done_testing(); diff --git a/qa/t/rest_user_suggest_whoami.t b/qa/t/rest_user_suggest_whoami.t new file mode 100644 index 0000000000..946feabf36 --- /dev/null +++ b/qa/t/rest_user_suggest_whoami.t @@ -0,0 +1,95 @@ +#!/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 calls to User.suggest() and whoami # +# GET /rest/user/suggest # +# GET /rest/whoami # +##################################################### + +use 5.10.1; +use strict; +use warnings; +use lib qw(lib ../../lib ../../local/lib/perl5); + +use Bugzilla; +use QA::Util qw(get_config); +use QA::REST::Util qw(api_headers rest_get_url); + +use Test::Mojo; +use Test::More; + +my $config = get_config(); +my $url = Bugzilla->localconfig->urlbase; + +my $login = $config->{unprivileged_user_login}; +my $headers = api_headers($config->{unprivileged_user_api_key}); +my $anon = api_headers(undef); + +my $t = Test::Mojo->new(); +$t->ua->max_redirects(1); + +########### +# suggest # +########### + +$t->get_ok($url . 'rest/user/suggest' => $headers) + ->status_isnt(200) + ->json_like('/message' => qr/one of the following parameters/); + +$t->get_ok(rest_get_url($url, 'rest/user/suggest', {match => $login}) => $anon) + ->status_isnt(200) + ->json_like('/message' => qr/Logged-out users cannot use/); + +$t->get_ok(rest_get_url($url, 'rest/user/suggest', {match => 'no'}) => $headers) + ->status_is(200) + ->json_is('/users' => []); + +# Same call as the user autocomplete in js/field.js. "requests" is added by +# the Review extension through the webservice_user_get hook. +$t->get_ok( + rest_get_url($url, 'rest/user/suggest', {match => $login}) => $headers) + ->status_is(200) + ->json_is('/users/0/name' => $login) + ->json_has('/users/0/id') + ->json_has('/users/0/real_name') + ->json_has('/users/0/nick') + ->json_has('/users/0/requests/review/blocked'); + +$t->get_ok( + rest_get_url($url, 'rest/user/suggest', + {match => $login, include_fields => 'gravatar'}) => $headers + ) + ->status_is(200) + ->json_has('/users/0/gravatar') + ->json_hasnt('/users/0/requests'); + +########## +# whoami # +########## + +$t->get_ok($url . 'rest/whoami' => $anon)->status_is(401); + +$t->get_ok($url . 'rest/whoami' => $headers) + ->status_is(200) + ->json_is('/name' => $login) + ->json_is('/mfa_status' => Mojo::JSON->false) + ->json_is('/groups' => []) + ->json_like('/id' => qr/^\d+$/) + ->json_like('/uuid' => qr/^bmo-who:[0-9a-f]{40}$/); + +$t->get_ok( + rest_get_url($url, 'rest/whoami', {include_fields => 'id,name'}) => $headers) + ->status_is(200); +is_deeply( + [sort keys %{$t->tx->res->json}], + ['id', 'name'], + 'whoami honours include_fields' +); + +done_testing(); From 97432b312e0e63d29ea032359abe4b96a8f33d95 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 8 Oct 2026 12:25:08 +0200 Subject: [PATCH 2/9] Bug 2078690 - Drop api_key and token before set_all() in User update, with test --- Bugzilla/API/V1/UserObject.pm | 3 ++- qa/t/rest_user_update_protected.t | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm index 5da28c0b0e..1f045c71a0 100644 --- a/Bugzilla/API/V1/UserObject.pm +++ b/Bugzilla/API/V1/UserObject.pm @@ -449,7 +449,8 @@ sub update { # field rather than silently ignoring it. my $values = translate($params, MAPPED_FIELDS); delete @$values{qw(ids names include_fields exclude_fields - Bugzilla_api_key Bugzilla_api_token Bugzilla_login Bugzilla_password)}; + Bugzilla_api_key Bugzilla_api_token Bugzilla_login Bugzilla_password + api_key token)}; my $dbh = Bugzilla->dbh; $dbh->bz_start_transaction(); diff --git a/qa/t/rest_user_update_protected.t b/qa/t/rest_user_update_protected.t index bf0d02ba2a..25d7421566 100644 --- a/qa/t/rest_user_update_protected.t +++ b/qa/t/rest_user_update_protected.t @@ -136,4 +136,16 @@ $t->put_ok($url ->status_is(200) ->json_has('/users', 'An admin can still update an admin account'); +# +# 6. The deprecated ?api_key= query parameter authenticates the request +# without being passed on as a user field. This also restores the name +# changed in 2. +# +$t->put_ok($url + . "rest/user/$target_login?api_key=$admin_api_key" => json => + {full_name => $target_realname}) + ->status_is(200) + ->json_is('/users/0/changes/full_name/added', + $target_realname, 'An API key in the query string is not a user field'); + done_testing(); From 94d23483f8fbf37c6c891c7c45ba453d4085b840 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 8 Oct 2026 12:37:59 +0200 Subject: [PATCH 3/9] Bug 2078690 - Report api_key_not_valid/api_key_revoked from whoami, with test --- Bugzilla/API/V1/UserObject.pm | 26 +++++++++++++- t/app-user-whoami.t | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 t/app-user-whoami.t diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm index 1f045c71a0..d88b3ed841 100644 --- a/Bugzilla/API/V1/UserObject.pm +++ b/Bugzilla/API/V1/UserObject.pm @@ -18,6 +18,7 @@ use Bugzilla::Group; use Bugzilla::Hook; use Bugzilla::Logging; use Bugzilla::User; +use Bugzilla::User::APIKey; use Bugzilla::Util qw(datetime_from detaint_natural email_filter mojo_user_agent trim); use Bugzilla::WebService::Util @@ -499,7 +500,8 @@ sub whoami { my $user = $self->_user_from_phab_token; if (!$user) { $user = $self->bugzilla->login; - $user->id || return $self->user_error('login_required'); + $user->id + || return $self->user_error($self->_api_key_error // 'login_required'); } my ($params, $error) = $self->_request_params; @@ -550,6 +552,28 @@ sub _request_params { return ($params, undef); } +# bugzilla.login treats an API key it refuses as no API key at all. Clients +# use whoami to validate a key, so say why it was refused, as the legacy login +# did (Bugzilla::Auth::Login::APIKey). +sub _api_key_error { + my ($self) = @_; + + my $query = $self->req->query_params; + my $api_key_text + = $self->req->headers->header('X-Bugzilla-API-Key') + || $query->param('Bugzilla_api_key') + || $query->param('api_key'); + return undef if !$api_key_text; + + my $api_key = Bugzilla::User::APIKey->new({name => $api_key_text}); + return 'api_key_not_valid' if !$api_key; + return 'api_key_not_valid' + if $api_key->sticky + && $api_key->last_used_ip + && $api_key->last_used_ip ne $self->tx->remote_address; + return $api_key->revoked ? 'api_key_revoked' : 'api_key_not_valid'; +} + sub _user_from_phab_token { my ($self) = @_; diff --git a/t/app-user-whoami.t b/t/app-user-whoami.t new file mode 100644 index 0000000000..24dd6807c3 --- /dev/null +++ b/t/app-user-whoami.t @@ -0,0 +1,68 @@ +#!/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. +use strict; +use warnings; +use 5.10.1; +use lib qw( . lib local/lib/perl5 ); + +BEGIN { + $ENV{LOG4PERL_CONFIG_FILE} = 'log4perl-t.conf'; + $ENV{BUGZILLA_DISABLE_HOSTAGE} = 1; +} + +use Bugzilla::Test::MockLocalconfig (urlbase => 'http://bmo.test'); +use Bugzilla::Test::MockDB; +use Bugzilla::Test::MockParams; +use Bugzilla::Test::Util qw(create_user issue_api_key); + +use Bugzilla::Constants; +use Test2::V0; +use Test::Mojo; + +# GET /rest/whoami is how a client validates an API key, so a key that is +# refused must say why (api_key_not_valid / api_key_revoked, internal code +# 306), as the legacy endpoint did, rather than the generic login_required +# (410) an anonymous request gets. + +my $user = create_user('whoami@mozilla.org', '*'); +my $key = issue_api_key('whoami@mozilla.org'); + +my $t = Test::Mojo->new('Bugzilla::App'); + +$t->get_ok('/rest/whoami' => {'X-Bugzilla-API-Key' => $key->api_key}) + ->status_is(200) + ->json_is('/id' => $user->id); + +# No credentials at all. +$t->get_ok('/rest/whoami')->status_is(401)->json_is('/code' => 410); + +# An unknown key, in the header or in the deprecated query parameters. +$t->get_ok('/rest/whoami' => {'X-Bugzilla-API-Key' => 'bogus-key-value'}) + ->status_isnt(200) + ->json_is('/code' => 306) + ->json_like('/message' => qr/API key you specified is invalid/); + +foreach my $param (qw(api_key Bugzilla_api_key)) { + $t->get_ok("/rest/whoami?$param=bogus-key-value") + ->status_isnt(200) + ->json_is('/code' => 306) + ->json_like('/message' => qr/API key you specified is invalid/); +} + +# A revoked key. +Bugzilla->set_user(Bugzilla::User->super_user); +$key->set_revoked(1); +$key->update(); +Bugzilla->set_user(Bugzilla::User->new); + +$t->get_ok('/rest/whoami' => {'X-Bugzilla-API-Key' => $key->api_key}) + ->status_isnt(200) + ->json_is('/code' => 306) + ->json_like('/message' => qr/has been revoked/); + +done_testing; From 23242c599ebaa4456857ea8cb29d927dae8acbcf Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 8 Oct 2026 12:46:49 +0200 Subject: [PATCH 4/9] Bug 2078690 - Refuse disabled accounts on whoami's Phabricator token path, with test --- Bugzilla/API/V1/UserObject.pm | 8 +++++- t/app-user-whoami.t | 49 +++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm index d88b3ed841..26ca00c512 100644 --- a/Bugzilla/API/V1/UserObject.pm +++ b/Bugzilla/API/V1/UserObject.pm @@ -498,7 +498,13 @@ sub whoami { my ($self) = @_; my $user = $self->_user_from_phab_token; - if (!$user) { + if ($user) { + + # bugzilla.login is not involved here, so refuse a disabled account the + # way it does. + $self->bugzilla->assert_account_usable($user); + } + else { $user = $self->bugzilla->login; $user->id || return $self->user_error($self->_api_key_error // 'login_required'); diff --git a/t/app-user-whoami.t b/t/app-user-whoami.t index 24dd6807c3..5b76ddde19 100644 --- a/t/app-user-whoami.t +++ b/t/app-user-whoami.t @@ -17,11 +17,12 @@ BEGIN { use Bugzilla::Test::MockLocalconfig (urlbase => 'http://bmo.test'); use Bugzilla::Test::MockDB; -use Bugzilla::Test::MockParams; -use Bugzilla::Test::Util qw(create_user issue_api_key); +use Bugzilla::Test::MockParams (phabricator_base_uri => 'http://phab.test/'); +use Bugzilla::Test::Util qw(create_user issue_api_key mock_useragent_tx); use Bugzilla::Constants; use Test2::V0; +use Mojo::JSON qw(encode_json); use Test::Mojo; # GET /rest/whoami is how a client validates an API key, so a key that is @@ -65,4 +66,48 @@ $t->get_ok('/rest/whoami' => {'X-Bugzilla-API-Key' => $key->api_key}) ->json_is('/code' => 306) ->json_like('/message' => qr/has been revoked/); +# Phabricator calls whoami with X-Phabricator-Token instead of an API key, to +# learn who the token belongs to and whether they have MFA enabled. Bugzilla +# asks Phabricator's user.whoami for the token's email; that request is +# stubbed here. +my $phab_response; +{ + + package FakePhabUA; + sub new { return bless {}, shift } + sub transactor { return $_[0] } + sub name {return} + sub get { return Bugzilla::Test::Util::mock_useragent_tx($phab_response) } +} +my $ua_mock = mock 'Bugzilla::API::V1::UserObject' => + (override => [mojo_user_agent => sub { FakePhabUA->new }]); + +my $phab_user = create_user('phab@mozilla.org', '*'); +my %phab = ('X-Phabricator-Token' => 'api-stub'); + +$phab_response = encode_json({result => {primaryEmail => 'phab@mozilla.org'}}); +$t->get_ok('/rest/whoami' => \%phab) + ->status_is(200) + ->json_is('/id' => $phab_user->id) + ->json_is('/mfa_status' => Mojo::JSON->false) + ->json_is('/groups' => []); + +# A token Phabricator rejects, or one for an email Bugzilla does not know. +$phab_response = encode_json({error_info => 'API token is not valid.'}); +$t->get_ok('/rest/whoami' => \%phab)->status_isnt(200)->json_is('/code' => 306); + +$phab_response + = encode_json({result => {primaryEmail => 'nobody@mozilla.org'}}); +$t->get_ok('/rest/whoami' => \%phab)->status_isnt(200)->json_is('/code' => 306); + +# A disabled account is refused (account_disabled, internal code 301), as it +# is with an API key. +Bugzilla->set_user(Bugzilla::User->super_user); +$phab_user->set_disabledtext('Contract ended. Access revoked.'); +$phab_user->update(); +Bugzilla->set_user(Bugzilla::User->new); + +$phab_response = encode_json({result => {primaryEmail => 'phab@mozilla.org'}}); +$t->get_ok('/rest/whoami' => \%phab)->status_is(401)->json_is('/code' => 301); + done_testing; From a7702e1ccc02292b1ec3041175701dc8bd525886 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 8 Oct 2026 12:51:45 +0200 Subject: [PATCH 5/9] Bug 2078690 - Ignore a null include_fields/exclude_fields, with test --- Bugzilla/API/V1/UserObject.pm | 2 +- qa/t/rest_user_update_protected.t | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm index 26ca00c512..3c9568cdcd 100644 --- a/Bugzilla/API/V1/UserObject.pm +++ b/Bugzilla/API/V1/UserObject.pm @@ -552,7 +552,7 @@ sub _request_params { for my $field (qw(include_fields exclude_fields)) { $params->{$field} = [map { split(/[\s,]+/) } @{$params->{$field}}] - if exists $params->{$field}; + if defined $params->{$field}; } return ($params, undef); diff --git a/qa/t/rest_user_update_protected.t b/qa/t/rest_user_update_protected.t index 25d7421566..33bd93734d 100644 --- a/qa/t/rest_user_update_protected.t +++ b/qa/t/rest_user_update_protected.t @@ -148,4 +148,13 @@ $t->put_ok($url ->json_is('/users/0/changes/full_name/added', $target_realname, 'An API key in the query string is not a user field'); +# +# 7. A null include_fields in the JSON body is ignored, not a server error. +# +$t->put_ok($url + . "rest/user/$target_login" => {'X-Bugzilla-API-Key' => $admin_api_key} => + json => {full_name => $target_realname, include_fields => undef}) + ->status_is(200) + ->json_has('/users', 'A null include_fields is ignored'); + done_testing(); From 13a6ffff404793a65b35692764fcfe8a3346ffe7 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 8 Oct 2026 13:31:21 +0200 Subject: [PATCH 6/9] Bug 2078690 - Port offer_account_by_email to UserObject --- Bugzilla/API/V1/UserObject.pm | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm index 3c9568cdcd..667347732f 100644 --- a/Bugzilla/API/V1/UserObject.pm +++ b/Bugzilla/API/V1/UserObject.pm @@ -48,6 +48,10 @@ sub setup_routes { $routes->get('/user/suggest')->to('V1::UserObject#suggest'); $routes->options('/user/suggest')->to('V1::UserObject#options', allow => 'GET'); + # Likewise, and its OPTIONS route must not be answered by the /user/#id_or_name one. + $routes->post('/user/offer_account_by_email')->to('V1::UserObject#offer_account_by_email'); + $routes->options('/user/offer_account_by_email')->to('V1::UserObject#options', allow => 'POST'); + $routes->get('/user')->to('V1::UserObject#get'); $routes->post('/user')->to('V1::UserObject#create'); $routes->get('/user/#id_or_name')->to('V1::UserObject#get'); @@ -90,6 +94,23 @@ sub type { return $value; } +# No login here, as in the legacy method (LOGIN_EXEMPT): this is how someone +# without an account asks for one. +sub offer_account_by_email { + my ($self) = @_; + + my ($params, $error) = merge_request_params($self); + return $self->user_error($error) if $error; + + my $email = trim($params->{email}) + || return $self->code_error('param_required', {param => 'email'}); + + Bugzilla->user->check_account_creation_enabled; + Bugzilla->user->check_and_send_account_creation_confirmation($email); + + return $self->render(json => undef); +} + sub create { my ($self) = @_; From 2735b58ec6cb74e934997ed5fccc7dc005414d00 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 8 Oct 2026 14:09:51 +0200 Subject: [PATCH 7/9] Bug 2078690 - Fix t/app-user-whoami.t: create users before the first request, shorten method chains, fix test data --- t/app-user-whoami.t | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/t/app-user-whoami.t b/t/app-user-whoami.t index 5b76ddde19..0aa99ae716 100644 --- a/t/app-user-whoami.t +++ b/t/app-user-whoami.t @@ -30,8 +30,12 @@ use Test::Mojo; # 306), as the legacy endpoint did, rather than the generic login_required # (410) an anonymous request gets. -my $user = create_user('whoami@mozilla.org', '*'); -my $key = issue_api_key('whoami@mozilla.org'); +# Every account is created before the first request. The BMO extension logs +# the remote IP of whoever creates a user, which outside of a request only +# works until a request has left its controller behind. +my $user = create_user('whoami@mozilla.org', '*'); +my $phab_user = create_user('phab@mozilla.org', '*'); +my $key = issue_api_key('whoami@mozilla.org'); my $t = Test::Mojo->new('Bugzilla::App'); @@ -44,14 +48,13 @@ $t->get_ok('/rest/whoami')->status_is(401)->json_is('/code' => 410); # An unknown key, in the header or in the deprecated query parameters. $t->get_ok('/rest/whoami' => {'X-Bugzilla-API-Key' => 'bogus-key-value'}) - ->status_isnt(200) - ->json_is('/code' => 306) + ->status_isnt(200); +$t->json_is('/code' => 306) ->json_like('/message' => qr/API key you specified is invalid/); foreach my $param (qw(api_key Bugzilla_api_key)) { - $t->get_ok("/rest/whoami?$param=bogus-key-value") - ->status_isnt(200) - ->json_is('/code' => 306) + $t->get_ok("/rest/whoami?$param=bogus-key-value")->status_isnt(200); + $t->json_is('/code' => 306) ->json_like('/message' => qr/API key you specified is invalid/); } @@ -62,9 +65,8 @@ $key->update(); Bugzilla->set_user(Bugzilla::User->new); $t->get_ok('/rest/whoami' => {'X-Bugzilla-API-Key' => $key->api_key}) - ->status_isnt(200) - ->json_is('/code' => 306) - ->json_like('/message' => qr/has been revoked/); + ->status_isnt(200); +$t->json_is('/code' => 306)->json_like('/message' => qr/has been revoked/); # Phabricator calls whoami with X-Phabricator-Token instead of an API key, to # learn who the token belongs to and whether they have MFA enabled. Bugzilla @@ -82,22 +84,18 @@ my $phab_response; my $ua_mock = mock 'Bugzilla::API::V1::UserObject' => (override => [mojo_user_agent => sub { FakePhabUA->new }]); -my $phab_user = create_user('phab@mozilla.org', '*'); -my %phab = ('X-Phabricator-Token' => 'api-stub'); +my %phab = ('X-Phabricator-Token' => 'api-stub'); $phab_response = encode_json({result => {primaryEmail => 'phab@mozilla.org'}}); -$t->get_ok('/rest/whoami' => \%phab) - ->status_is(200) - ->json_is('/id' => $phab_user->id) - ->json_is('/mfa_status' => Mojo::JSON->false) - ->json_is('/groups' => []); +$t->get_ok('/rest/whoami' => \%phab)->status_is(200); +$t->json_is('/id' => $phab_user->id) + ->json_is('/mfa_status' => Mojo::JSON->false); # A token Phabricator rejects, or one for an email Bugzilla does not know. $phab_response = encode_json({error_info => 'API token is not valid.'}); $t->get_ok('/rest/whoami' => \%phab)->status_isnt(200)->json_is('/code' => 306); -$phab_response - = encode_json({result => {primaryEmail => 'nobody@mozilla.org'}}); +$phab_response = encode_json({result => {primaryEmail => 'unknown@phab.test'}}); $t->get_ok('/rest/whoami' => \%phab)->status_isnt(200)->json_is('/code' => 306); # A disabled account is refused (account_disabled, internal code 301), as it From 7e46169c59618d598f6352dea0d426823f81c8e5 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Fri, 9 Oct 2026 17:42:32 +0200 Subject: [PATCH 8/9] Bug 2078690 - Ignore Bugzilla_token on user update, with test --- Bugzilla/API/V1/UserObject.pm | 2 +- qa/t/rest_user_update_protected.t | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm index 667347732f..0462939858 100644 --- a/Bugzilla/API/V1/UserObject.pm +++ b/Bugzilla/API/V1/UserObject.pm @@ -472,7 +472,7 @@ sub update { my $values = translate($params, MAPPED_FIELDS); delete @$values{qw(ids names include_fields exclude_fields Bugzilla_api_key Bugzilla_api_token Bugzilla_login Bugzilla_password - api_key token)}; + Bugzilla_token api_key token)}; my $dbh = Bugzilla->dbh; $dbh->bz_start_transaction(); diff --git a/qa/t/rest_user_update_protected.t b/qa/t/rest_user_update_protected.t index 33bd93734d..783c6f4f91 100644 --- a/qa/t/rest_user_update_protected.t +++ b/qa/t/rest_user_update_protected.t @@ -157,4 +157,14 @@ $t->put_ok($url ->status_is(200) ->json_has('/users', 'A null include_fields is ignored'); +# +# 8. Nor is a Bugzilla_token left in the query string. +# +$t->put_ok($url + . "rest/user/$target_login?Bugzilla_token=ignored" => + {'X-Bugzilla-API-Key' => $admin_api_key} => json => + {full_name => $target_realname}) + ->status_is(200) + ->json_has('/users', 'A Bugzilla_token in the query string is not a user field'); + done_testing(); From 05f82103f6e8ff47925ea3aad713f142113589a6 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Fri, 9 Oct 2026 17:43:35 +0200 Subject: [PATCH 9/9] Bug 2078690 - Move type() to Bugzilla::API::V1::Util, add double and base64 --- Bugzilla/API/V1/UserObject.pm | 24 ++++-------------- Bugzilla/API/V1/Util.pm | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 Bugzilla/API/V1/Util.pm diff --git a/Bugzilla/API/V1/UserObject.pm b/Bugzilla/API/V1/UserObject.pm index 0462939858..a58aa667b1 100644 --- a/Bugzilla/API/V1/UserObject.pm +++ b/Bugzilla/API/V1/UserObject.pm @@ -10,8 +10,9 @@ package Bugzilla::API::V1::UserObject; use 5.10.1; use Mojo::Base qw( Mojolicious::Controller ); -use Mojo::JSON qw(true false); +use Mojo::JSON qw(true); +use Bugzilla::API::V1::Util; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Group; @@ -19,8 +20,7 @@ use Bugzilla::Hook; use Bugzilla::Logging; use Bugzilla::User; use Bugzilla::User::APIKey; -use Bugzilla::Util - qw(datetime_from detaint_natural email_filter mojo_user_agent trim); +use Bugzilla::Util qw(detaint_natural mojo_user_agent trim); use Bugzilla::WebService::Util qw(filter filter_wants merge_request_params params_to_objects translate); @@ -74,24 +74,10 @@ sub options { # The webservice_user_get hook hands this controller to extensions as # "webservice", and Review, UserProfile and TagNewUsers call ->type on it, as -# they did on the legacy JSON-RPC/REST server. Same output as the legacy one. +# they did on the legacy JSON-RPC/REST server. sub type { my ($self, $type, $value) = @_; - - # This is the only type that does something special with undef. - return $value ? true : false if $type eq 'boolean'; - - return undef if !defined $value; - return int($value) if $type eq 'int'; - return "$value" if $type eq 'string'; - return email_filter($value) - if $type eq 'email' && Bugzilla->params->{webservice_email_filter}; - - # Always UTC, with the timezone specifier. - return $value ? datetime_from($value, 'UTC')->iso8601() . 'Z' : '' - if $type eq 'dateTime'; - - return $value; + return Bugzilla::API::V1::Util->type($type, $value); } # No login here, as in the legacy method (LOGIN_EXEMPT): this is how someone diff --git a/Bugzilla/API/V1/Util.pm b/Bugzilla/API/V1/Util.pm new file mode 100644 index 0000000000..18d342faec --- /dev/null +++ b/Bugzilla/API/V1/Util.pm @@ -0,0 +1,47 @@ +# 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 Mojo::JSON qw(true false); + +use Bugzilla; +use Bugzilla::Util qw(datetime_from email_filter); + +use MIME::Base64 qw(encode_base64); + +# Same output as type() on the legacy JSON-RPC/REST server. +sub type { + my ($class, $type, $value) = @_; + + # This is the only type that does something special with undef. + return $value ? true : false if $type eq 'boolean'; + + return undef if !defined $value; + return int($value) if $type eq 'int'; + return 0.0 + $value if $type eq 'double'; + return "$value" if $type eq 'string'; + return email_filter($value) + if $type eq 'email' && Bugzilla->params->{webservice_email_filter}; + + # Always UTC, with the timezone specifier. + return $value ? datetime_from($value, 'UTC')->iso8601() . 'Z' : '' + if $type eq 'dateTime'; + + if ($type eq 'base64') { + utf8::encode($value) if utf8::is_utf8($value); + return encode_base64($value, ''); + } + + return $value; +} + +1;