aboutsummaryrefslogtreecommitdiff
path: root/Bugzilla/WebService/User.pm
diff options
context:
space:
mode:
Diffstat (limited to 'Bugzilla/WebService/User.pm')
-rw-r--r--Bugzilla/WebService/User.pm946
1 files changed, 946 insertions, 0 deletions
diff --git a/Bugzilla/WebService/User.pm b/Bugzilla/WebService/User.pm
new file mode 100644
index 0000000..b865e11
--- /dev/null
+++ b/Bugzilla/WebService/User.pm
@@ -0,0 +1,946 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::WebService::User;
+
+use strict;
+use base qw(Bugzilla::WebService);
+
+use Bugzilla;
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Group;
+use Bugzilla::User;
+use Bugzilla::Util qw(trim detaint_natural);
+use Bugzilla::WebService::Util qw(filter validate translate params_to_objects);
+
+use List::Util qw(min);
+
+use List::Util qw(first);
+
+# Don't need auth to login
+use constant LOGIN_EXEMPT => {
+ login => 1,
+ offer_account_by_email => 1,
+};
+
+use constant READ_ONLY => qw(
+ get
+);
+
+use constant MAPPED_FIELDS => {
+ email => 'login',
+ full_name => 'name',
+ login_denied_text => 'disabledtext',
+ email_enabled => 'disable_mail'
+};
+
+use constant MAPPED_RETURNS => {
+ login_name => 'email',
+ realname => 'full_name',
+ disabledtext => 'login_denied_text',
+ disable_mail => 'email_enabled'
+};
+
+##############
+# User Login #
+##############
+
+sub login {
+ my ($self, $params) = @_;
+
+ # Username and password params are required
+ foreach my $param ("login", "password") {
+ defined $params->{$param}
+ || ThrowCodeError('param_required', { param => $param });
+ }
+
+ # Make sure the CGI user info class works if necessary.
+ my $input_params = Bugzilla->input_params;
+ $input_params->{'Bugzilla_login'} = $params->{login};
+ $input_params->{'Bugzilla_password'} = $params->{password};
+ $input_params->{'Bugzilla_restrictlogin'} = $params->{restrict_login};
+
+ my $user = Bugzilla->login();
+
+ my $result = { id => $self->type('int', $user->id) };
+
+ if ($user->{_login_token}) {
+ $result->{'token'} = $user->id . "-" . $user->{_login_token};
+ }
+
+ return $result;
+}
+
+sub logout {
+ my $self = shift;
+ Bugzilla->logout;
+}
+
+#################
+# User Creation #
+#################
+
+sub offer_account_by_email {
+ my $self = shift;
+ my ($params) = @_;
+ my $email = trim($params->{email})
+ || ThrowCodeError('param_required', { param => 'email' });
+
+ Bugzilla->user->check_account_creation_enabled;
+ Bugzilla->user->check_and_send_account_creation_confirmation($email);
+ return undef;
+}
+
+sub create {
+ my $self = shift;
+ my ($params) = @_;
+
+ Bugzilla->user->in_group('editusers')
+ || ThrowUserError("auth_failure", { group => "editusers",
+ action => "add",
+ object => "users"});
+
+ my $email = trim($params->{email})
+ || ThrowCodeError('param_required', { param => 'email' });
+ my $realname = trim($params->{full_name});
+ my $password = trim($params->{password}) || '*';
+
+ my $user = Bugzilla::User->create({
+ login_name => $email,
+ realname => $realname,
+ cryptpassword => $password
+ });
+
+ return { id => $self->type('int', $user->id) };
+}
+
+
+# function to return user information by passing either user ids or
+# login names or both together:
+# $call = $rpc->call( 'User.get', { ids => [1,2,3],
+# names => ['testusera@redhat.com', 'testuserb@redhat.com'] });
+sub get {
+ my ($self, $params) = validate(@_, 'names', 'ids');
+
+ Bugzilla->switch_to_shadow_db();
+
+ defined($params->{names}) || defined($params->{ids})
+ || defined($params->{match})
+ || ThrowCodeError('params_required',
+ { function => 'User.get', params => ['ids', 'names', 'match'] });
+
+ my @user_objects;
+ @user_objects = map { Bugzilla::User->check($_) } @{ $params->{names} }
+ if $params->{names};
+
+ # 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 (!Bugzilla->user->id){
+ if ($params->{ids}){
+ ThrowUserError("user_access_by_id_denied");
+ }
+ if ($params->{match}) {
+ ThrowUserError('user_access_by_match_denied');
+ }
+ my $in_group = $self->_filter_users_by_group(
+ \@user_objects, $params);
+ @users = map {filter $params, {
+ id => $self->type('int', $_->id),
+ real_name => $self->type('string', $_->name),
+ name => $self->type('string', $_->login),
+ }} @$in_group;
+
+ return { users => \@users };
+ }
+
+ my $obj_by_ids;
+ $obj_by_ids = Bugzilla::User->new_from_list($params->{ids}) if $params->{ids};
+
+ # obj_by_ids are only visible to the user if he can see
+ # the otheruser, for non visible otheruser throw an error
+ foreach my $obj (@$obj_by_ids) {
+ if (Bugzilla->user->can_see_user($obj)){
+ if (!$unique_users{$obj->id}) {
+ push (@user_objects, $obj);
+ $unique_users{$obj->id} = $obj;
+ }
+ }
+ else {
+ ThrowUserError('auth_failure', {reason => "not_visible",
+ action => "access",
+ object => "user",
+ userid => $obj->id});
+ }
+ }
+
+ # User Matching
+ my $limit = Bugzilla->params->{maxusermatches};
+ if ($params->{limit}) {
+ detaint_natural($params->{limit})
+ || ThrowCodeError('param_must_be_numeric',
+ { function => 'Bugzilla::WebService::User::match',
+ param => 'limit' });
+ $limit = $limit ? min($params->{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 (@$matched) {
+ if (!$unique_users{$user->id}) {
+ push(@user_objects, $user);
+ $unique_users{$user->id} = $user;
+ }
+ }
+ }
+
+ my $in_group = $self->_filter_users_by_group(
+ \@user_objects, $params);
+
+ foreach my $user (@$in_group) {
+ my $user_info = {
+ id => $self->type('int', $user->id),
+ real_name => $self->type('string', $user->name),
+ name => $self->type('string', $user->login),
+ email => $self->type('string', $user->email),
+ can_login => $self->type('boolean', $user->is_enabled ? 1 : 0),
+ };
+
+ if (Bugzilla->user->in_group('editusers')) {
+ $user_info->{email_enabled} = $self->type('boolean', $user->email_enabled);
+ $user_info->{login_denied_text} = $self->type('string', $user->disabledtext);
+ }
+
+ if (Bugzilla->user->id == $user->id) {
+ $user_info->{saved_searches} = [map { $self->_query_to_hash($_) } @{ $user->queries }];
+ $user_info->{saved_reports} = [map { $self->_report_to_hash($_) } @{ $user->reports }];
+ }
+
+ if (Bugzilla->user->id == $user->id || Bugzilla->user->in_group('editusers')) {
+ $user_info->{groups} = [map {$self->_group_to_hash($_)} @{ $user->groups }];
+ }
+ else {
+ $user_info->{groups} = $self->_filter_bless_groups($user->groups);
+ }
+
+ push(@users, filter($params, $user_info));
+ }
+
+ return { users => \@users };
+}
+
+###############
+# User Update #
+###############
+
+sub update {
+ my ($self, $params) = @_;
+
+ my $dbh = Bugzilla->dbh;
+
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+
+ # Reject access if there is no sense in continuing.
+ $user->in_group('editusers')
+ || ThrowUserError("auth_failure", {group => "editusers",
+ action => "edit",
+ object => "users"});
+
+ defined($params->{names}) || defined($params->{ids})
+ || ThrowCodeError('params_required',
+ { function => 'User.update', params => ['ids', 'names'] });
+
+ my $user_objects = params_to_objects($params, 'Bugzilla::User');
+
+ my $values = translate($params, MAPPED_FIELDS);
+
+ # We delete names and ids to keep only new values to set.
+ delete $values->{names};
+ delete $values->{ids};
+
+ $dbh->bz_start_transaction();
+ foreach my $user (@$user_objects){
+ $user->set_all($values);
+ }
+
+ my %changes;
+ foreach my $user (@$user_objects){
+ my $returned_changes = $user->update();
+ $changes{$user->id} = translate($returned_changes, MAPPED_RETURNS);
+ }
+ $dbh->bz_commit_transaction();
+
+ my @result;
+ foreach my $user (@$user_objects) {
+ my %hash = (
+ id => $user->id,
+ changes => {},
+ );
+
+ foreach my $field (keys %{ $changes{$user->id} }) {
+ my $change = $changes{$user->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];
+ $hash{changes}{$field} = {
+ removed => $self->type('string', $change->[0]),
+ added => $self->type('string', $change->[1])
+ };
+ }
+
+ push(@result, \%hash);
+ }
+
+ return { users => \@result };
+}
+
+sub _filter_users_by_group {
+ my ($self, $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 $user = Bugzilla->user;
+ my (@groups, %groups);
+
+ if ($group_ids) {
+ @groups = map { Bugzilla::Group->check({ id => $_ }) } @$group_ids;
+ $groups{$_->id} = $_ foreach @groups;
+ }
+ if ($group_names) {
+ foreach my $name (@$group_names) {
+ my $group = Bugzilla::Group->check({ name => $name, _error => 'invalid_group_name' });
+ $user->in_group($group) || ThrowUserError('invalid_group_name', { name => $name });
+ $groups{$group->id} = $group;
+ }
+ }
+ @groups = values %groups;
+
+ my @in_group = grep { $self->_user_in_any_group($_, \@groups) } @$users;
+ return \@in_group;
+}
+
+sub _user_in_any_group {
+ my ($self, $user, $groups) = @_;
+ foreach my $group (@$groups) {
+ return 1 if $user->in_group($group);
+ }
+ return 0;
+}
+
+sub _filter_bless_groups {
+ my ($self, $groups) = @_;
+ my $user = Bugzilla->user;
+
+ my @filtered_groups;
+ foreach my $group (@$groups) {
+ next unless $user->can_bless($group->id);
+ push(@filtered_groups, $self->_group_to_hash($group));
+ }
+
+ return \@filtered_groups;
+}
+
+sub _group_to_hash {
+ my ($self, $group) = @_;
+ my $item = {
+ id => $self->type('int', $group->id),
+ name => $self->type('string', $group->name),
+ description => $self->type('string', $group->description),
+ };
+ return $item;
+}
+
+sub _query_to_hash {
+ my ($self, $query) = @_;
+ my $item = {
+ id => $self->type('int', $query->id),
+ name => $self->type('string', $query->name),
+ query => $self->type('string', $query->url),
+ };
+ return $item;
+}
+
+sub _report_to_hash {
+ my ($self, $report) = @_;
+ my $item = {
+ id => $self->type('int', $report->id),
+ name => $self->type('string', $report->name),
+ query => $self->type('string', $report->query),
+ };
+ return $item;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::Webservice::User - The User Account and Login API
+
+=head1 DESCRIPTION
+
+This part of the Bugzilla API allows you to create User Accounts and
+log in/out using an existing account.
+
+=head1 METHODS
+
+See L<Bugzilla::WebService> for a description of how parameters are passed,
+and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean.
+
+=head1 Logging In and Out
+
+=head2 login
+
+B<STABLE>
+
+=over
+
+=item B<Description>
+
+Logging in, with a username and password, is required for many
+Bugzilla installations, in order to search for bugs, post new bugs,
+etc. This method logs in an user.
+
+=item B<Params>
+
+=over
+
+=item C<login> (string) - The user's login name.
+
+=item C<password> (string) - The user's password.
+
+=item C<restrict_login> (bool) B<Optional> - If set to a true value,
+the token returned by this method will only be valid from the IP address
+which called this method.
+
+=back
+
+=item B<Returns>
+
+On success, a hash containing two items, C<id>, the numeric id of the
+user that was logged in, and a C<token> which can be passed in the parameters
+as authentication in other calls. The token can be sent along with any future
+requests to the webservice, for the duration of the session, i.e. till
+L<User.logout|/logout> is called.
+
+=item B<Errors>
+
+=over
+
+=item 300 (Invalid Username or Password)
+
+The username does not exist, or the password is wrong.
+
+=item 301 (Login Disabled)
+
+The ability to login with this account has been disabled. A reason may be
+specified with the error.
+
+=item 305 (New Password Required)
+
+The current password is correct, but the user is asked to change
+his password.
+
+=item 50 (Param Required)
+
+A login or password parameter was not provided.
+
+=back
+
+=item B<History>
+
+=over
+
+=item C<remember> was removed in Bugzilla B<4.4> as this method no longer
+creates a login cookie.
+
+=item C<restrict_login> was added in Bugzilla B<4.4>.
+
+=item C<token> was added in Bugzilla B<4.4>.
+
+=back
+
+=back
+
+=head2 logout
+
+B<STABLE>
+
+=over
+
+=item B<Description>
+
+Log out the user. Does nothing if there is no user logged in.
+
+=item B<Params> (none)
+
+=item B<Returns> (nothing)
+
+=item B<Errors> (none)
+
+=back
+
+=head1 Account Creation and Modification
+
+=head2 offer_account_by_email
+
+B<STABLE>
+
+=over
+
+=item B<Description>
+
+Sends an email to the user, offering to create an account. The user
+will have to click on a URL in the email, and choose their password
+and real name.
+
+This is the recommended way to create a Bugzilla account.
+
+=item B<Param>
+
+=over
+
+=item C<email> (string) - the email to send the offer to.
+
+=back
+
+=item B<Returns> (nothing)
+
+=item B<Errors>
+
+=over
+
+=item 500 (Account Already Exists)
+
+An account with that email address already exists in Bugzilla.
+
+=item 501 (Illegal Email Address)
+
+This Bugzilla does not allow you to create accounts with the format of
+email address you specified. Account creation may be entirely disabled.
+
+=back
+
+=back
+
+=head2 create
+
+B<STABLE>
+
+=over
+
+=item B<Description>
+
+Creates a user account directly in Bugzilla, password and all.
+Instead of this, you should use L</offer_account_by_email> when
+possible, because that makes sure that the email address specified can
+actually receive an email. This function does not check that.
+
+You must be logged in and have the C<editusers> privilege in order to
+call this function.
+
+=item B<Params>
+
+=over
+
+=item C<email> (string) - The email address for the new user.
+
+=item C<full_name> (string) B<Optional> - The user's full name. Will
+be set to empty if not specified.
+
+=item C<password> (string) B<Optional> - The password for the new user
+account, in plain text. It will be stripped of leading and trailing
+whitespace. If blank or not specified, the newly created account will
+exist in Bugzilla, but will not be allowed to log in using DB
+authentication until a password is set either by the user (through
+resetting their password) or by the administrator.
+
+=back
+
+=item B<Returns>
+
+A hash containing one item, C<id>, the numeric id of the user that was
+created.
+
+=item B<Errors>
+
+The same as L</offer_account_by_email>. If a password is specified,
+the function may also throw:
+
+=over
+
+=item 502 (Password Too Short)
+
+The password specified is too short. (Usually, this means the
+password is under three characters.)
+
+=back
+
+=item B<History>
+
+=over
+
+=item Error 503 (Password Too Long) removed in Bugzilla B<3.6>.
+
+=back
+
+=back
+
+=head2 update
+
+B<EXPERIMENTAL>
+
+=over
+
+=item B<Description>
+
+Updates user accounts in Bugzilla.
+
+=item B<Params>
+
+=over
+
+=item C<ids>
+
+C<array> Contains ids of user to update.
+
+=item C<names>
+
+C<array> Contains email/login of user to update.
+
+=item C<full_name>
+
+C<string> The new name of the user.
+
+=item C<email>
+
+C<string> The email of the user. Note that email used to login to bugzilla.
+Also note that you can only update one user at a time when changing the
+login name / email. (An error will be thrown if you try to update this field
+for multiple users at once.)
+
+=item C<password>
+
+C<string> The password of the user.
+
+=item C<email_enabled>
+
+C<boolean> A boolean value to enable/disable sending bug-related mail to the user.
+
+=item C<login_denied_text>
+
+C<string> A text field that holds the reason for disabling a user from logging
+into bugzilla, if empty then the user account is enabled otherwise it is
+disabled/closed.
+
+=back
+
+=item B<Returns>
+
+A C<hash> with a single field "users". This points to an array of hashes
+with the following fields:
+
+=over
+
+=item C<id>
+
+C<int> The id of the user that was updated.
+
+=item C<changes>
+
+C<hash> The changes that were actually done on this user. The keys are
+the names of the fields that were changed, and the values are a hash
+with two keys:
+
+=over
+
+=item C<added>
+
+C<string> The values that were added to this field,
+possibly a comma-and-space-separated list if multiple values were added.
+
+=item C<removed>
+
+C<string> The values that were removed from this field, possibly a
+comma-and-space-separated list if multiple values were removed.
+
+=back
+
+=back
+
+=item B<Errors>
+
+=over
+
+=item 51 (Bad Login Name)
+
+You passed an invalid login name in the "names" array.
+
+=item 304 (Authorization Required)
+
+Logged-in users are not authorized to edit other users.
+
+=back
+
+=back
+
+=head1 User Info
+
+=head2 get
+
+B<STABLE>
+
+=over
+
+=item B<Description>
+
+Gets information about user accounts in Bugzilla.
+
+=item B<Params>
+
+B<Note>: At least one of C<ids>, C<names>, or C<match> must be specified.
+
+B<Note>: Users will not be returned more than once, so even if a user
+is matched by more than one argument, only one user will be returned.
+
+In addition to the parameters below, this method also accepts the
+standard L<include_fields|Bugzilla::WebService/include_fields> and
+L<exclude_fields|Bugzilla::WebService/exclude_fields> arguments.
+
+=over
+
+=item C<ids> (array)
+
+An array of integers, representing user ids.
+
+Logged-out users cannot pass this parameter to this function. If they try,
+they will get an error. Logged-in users will get an error if they specify
+the id of a user they cannot see.
+
+=item C<names> (array)
+
+An array of login names (strings).
+
+=item C<match> (array)
+
+An array of strings. This works just like "user matching" in
+Bugzilla itself. Users will be returned whose real name or login name
+contains any one of the specified strings. Users that you cannot see will
+not be included in the returned list.
+
+Most installations have a limit on how many matches are returned for
+each string, which defaults to 1000 but can be changed by the Bugzilla
+administrator.
+
+Logged-out users cannot use this argument, and an error will be thrown
+if they try. (This is to make it harder for spammers to harvest email
+addresses from Bugzilla, and also to enforce the user visibility
+restrictions that are implemented on some Bugzillas.)
+
+=item C<limit> (int)
+
+Limit the number of users matched by the C<match> parameter. If value
+is greater than the system limit, the system limit will be used. This
+parameter is only used when user matching using the C<match> parameter
+is being performed.
+
+=item C<group_ids> (array)
+
+=item C<groups> (array)
+
+C<group_ids> is an array of numeric ids for groups that a user can be in.
+C<groups> is an array of names of groups that a user can be in.
+If these are specified, they limit the return value to users who are
+in I<any> of the groups specified.
+
+=item C<include_disabled> (boolean)
+
+By default, when using the C<match> parameter, disabled users are excluded
+from the returned results unless their full username is identical to the
+match string. Setting C<include_disabled> to C<true> will include disabled
+users in the returned results even if their username doesn't fully match
+the input string.
+
+=back
+
+=item B<Returns>
+
+A hash containing one item, C<users>, that is an array of
+hashes. Each hash describes a user, and has the following items:
+
+=over
+
+=item id
+
+C<int> The unique integer ID that Bugzilla uses to represent this user.
+Even if the user's login name changes, this will not change.
+
+=item real_name
+
+C<string> The actual name of the user. May be blank.
+
+=item email
+
+C<string> The email address of the user.
+
+=item name
+
+C<string> The login name of the user. Note that in some situations this is
+different than their email.
+
+=item can_login
+
+C<boolean> A boolean value to indicate if the user can login into bugzilla.
+
+=item email_enabled
+
+C<boolean> A boolean value to indicate if bug-related mail will be sent
+to the user or not.
+
+=item login_denied_text
+
+C<string> A text field that holds the reason for disabling a user from logging
+into bugzilla, if empty then the user account is enabled. Otherwise it is
+disabled/closed.
+
+=item groups
+
+C<array> An array of group hashes the user is a member of. If the currently
+logged in user is querying his own account or is a member of the 'editusers'
+group, the array will contain all the groups that the user is a
+member of. Otherwise, the array will only contain groups that the logged in
+user can bless. Each hash describes the group and contains the following items:
+
+=over
+
+=item id
+
+C<int> The group id
+
+=item name
+
+C<string> The name of the group
+
+=item description
+
+C<string> The description for the group
+
+=back
+
+=item saved_searches
+
+C<array> An array of hashes, each of which represents a user's saved search and has
+the following keys:
+
+=over
+
+=item id
+
+C<int> An integer id uniquely identifying the saved search.
+
+=item name
+
+C<string> The name of the saved search.
+
+=item query
+
+C<string> The CGI parameters for the saved search.
+
+=back
+
+=item saved_reports
+
+C<array> An array of hashes, each of which represents a user's saved report and has
+the following keys:
+
+=over
+
+=item id
+
+C<int> An integer id uniquely identifying the saved report.
+
+=item name
+
+C<string> The name of the saved report.
+
+=item query
+
+C<string> The CGI parameters for the saved report.
+
+=back
+
+B<Note>: If you are not logged in to Bugzilla when you call this function, you
+will only be returned the C<id>, C<name>, and C<real_name> items. If you are
+logged in and not in editusers group, you will only be returned the C<id>, C<name>,
+C<real_name>, C<email>, C<can_login>, and C<groups> items. The groups returned are
+filtered based on your permission to bless each group.
+The C<saved_searches> and C<saved_reports> items are only returned if you are
+querying your own account, even if you are in the editusers group.
+
+=back
+
+=item B<Errors>
+
+=over
+
+=item 51 (Bad Login Name or Group ID)
+
+You passed an invalid login name in the "names" array or a bad
+group ID in the C<group_ids> argument.
+
+=item 52 (Invalid Parameter)
+
+The value used must be an integer greater then zero.
+
+=item 304 (Authorization Required)
+
+You are logged in, but you are not authorized to see one of the users you
+wanted to get information about by user id.
+
+=item 505 (User Access By Id or User-Matching Denied)
+
+Logged-out users cannot use the "ids" or "match" arguments to this
+function.
+
+=item 804 (Invalid Group Name)
+
+You passed a group name in the C<groups> argument which either does not
+exist or you do not belong to it.
+
+=back
+
+=item B<History>
+
+=over
+
+=item Added in Bugzilla B<3.4>.
+
+=item C<group_ids> and C<groups> were added in Bugzilla B<4.0>.
+
+=item C<include_disabled> was added in Bugzilla B<4.0>. Default
+behavior for C<match> was changed to only return enabled accounts.
+
+=item Error 804 has been added in Bugzilla 4.0.9 and 4.2.4. It's now
+illegal to pass a group name you don't belong to.
+
+=item C<groups>, C<saved_searches>, and C<saved_reports> were added
+in Bugzilla B<4.4>.
+
+=back
+
+=back