# 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::FlagType; use 5.10.1; use strict; =head1 NAME Bugzilla::FlagType - A module to deal with Bugzilla flag types. =head1 SYNOPSIS FlagType.pm provides an interface to flag types as stored in Bugzilla. See below for more information. =head1 NOTES =over =item * Use of private functions/variables outside this module may lead to unexpected results after an upgrade. Please avoid using private functions in other files/modules. Private functions are functions whose names start with _ or are specifically noted as being private. =back =cut use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Util; use Bugzilla::Group; use Email::Address; use parent qw(Bugzilla::Object); ############################### #### Initialization #### ############################### use constant DB_TABLE => 'flagtypes'; use constant LIST_ORDER => 'sortkey, name'; use constant DB_COLUMNS => qw( id name description cc_list target_type sortkey is_active is_requestable is_requesteeble is_multiplicable grant_group_id request_group_id ); use constant UPDATE_COLUMNS => qw( name description cc_list sortkey is_active is_requestable is_requesteeble is_multiplicable grant_group_id request_group_id ); use constant VALIDATORS => { name => \&_check_name, description => \&_check_description, cc_list => \&_check_cc_list, target_type => \&_check_target_type, sortkey => \&_check_sortkey, is_active => \&Bugzilla::Object::check_boolean, is_requestable => \&Bugzilla::Object::check_boolean, is_requesteeble => \&Bugzilla::Object::check_boolean, is_multiplicable => \&Bugzilla::Object::check_boolean, grant_group => \&_check_group, request_group => \&_check_group, }; use constant UPDATE_VALIDATORS => { grant_group_id => \&_check_group, request_group_id => \&_check_group, }; ############################### sub create { my $class = shift; my $dbh = Bugzilla->dbh; $dbh->bz_start_transaction(); $class->check_required_create_fields(@_); my $params = $class->run_create_validators(@_); # In the DB, only the first character of the target type is stored. $params->{target_type} = substr($params->{target_type}, 0, 1); # Extract everything which is not a valid column name. $params->{grant_group_id} = delete $params->{grant_group}; $params->{request_group_id} = delete $params->{request_group}; my $inclusions = delete $params->{inclusions}; my $exclusions = delete $params->{exclusions}; my $flagtype = $class->insert_create_data($params); $flagtype->set_clusions({ inclusions => $inclusions, exclusions => $exclusions }); $flagtype->update(); $dbh->bz_commit_transaction(); return $flagtype; } sub update { my $self = shift; my $dbh = Bugzilla->dbh; my $flag_id = $self->id; $dbh->bz_start_transaction(); my $changes = $self->SUPER::update(@_); # Update the flaginclusions and flagexclusions tables. foreach my $category ('inclusions', 'exclusions') { next unless delete $self->{"_update_$category"}; $dbh->do("DELETE FROM flag$category WHERE type_id = ?", undef, $flag_id); my $sth = $dbh->prepare("INSERT INTO flag$category (type_id, product_id, component_id) VALUES (?, ?, ?)"); foreach my $prod_comp (values %{$self->{$category}}) { my ($prod_id, $comp_id) = split(':', $prod_comp); $prod_id ||= undef; $comp_id ||= undef; $sth->execute($flag_id, $prod_id, $comp_id); } $changes->{$category} = [0, 1]; } # Clear existing flags for bugs/attachments in categories no longer on # the list of inclusions or that have been added to the list of exclusions. my $flag_ids = $dbh->selectcol_arrayref('SELECT DISTINCT flags.id FROM flags INNER JOIN bugs ON flags.bug_id = bugs.bug_id LEFT JOIN flaginclusions AS i ON (flags.type_id = i.type_id AND (bugs.product_id = i.product_id OR i.product_id IS NULL) AND (bugs.component_id = i.component_id OR i.component_id IS NULL)) WHERE flags.type_id = ? AND i.type_id IS NULL', undef, $self->id); Bugzilla::Flag->force_retarget($flag_ids); $flag_ids = $dbh->selectcol_arrayref('SELECT DISTINCT flags.id FROM flags INNER JOIN bugs ON flags.bug_id = bugs.bug_id INNER JOIN flagexclusions AS e ON flags.type_id = e.type_id WHERE flags.type_id = ? AND (bugs.product_id = e.product_id OR e.product_id IS NULL) AND (bugs.component_id = e.component_id OR e.component_id IS NULL)', undef, $self->id); Bugzilla::Flag->force_retarget($flag_ids); # Silently remove requestees from flags which are no longer # specifically requestable. if (!$self->is_requesteeble) { my $ids = $dbh->selectcol_arrayref( 'SELECT id FROM flags WHERE type_id = ? AND requestee_id IS NOT NULL', undef, $self->id); if (@$ids) { $dbh->do('UPDATE flags SET requestee_id = NULL WHERE ' . $dbh->sql_in('id', $ids)); foreach my $id (@$ids) { Bugzilla->memcached->clear({ table => 'flags', id => $id }); } } } $dbh->bz_commit_transaction(); return $changes; } ############################### #### Accessors ###### ############################### =head2 METHODS =over =item C Returns the ID of the flagtype. =item C Returns the name of the flagtype. =item C Returns the description of the flagtype. =item C Returns the concatenated CC list for the flagtype, as a single string. =item C Returns whether the flagtype applies to bugs or attachments. =item C Returns whether the flagtype is active or disabled. Flags being in a disabled flagtype are not deleted. It only prevents you from adding new flags to it. =item C Returns whether you can request for the given flagtype (i.e. whether the '?' flag is available or not). =item C Returns whether you can ask someone specifically or not. =item C Returns whether you can have more than one flag for the given flagtype in a given bug/attachment. =item C Returns the sortkey of the flagtype. =back =cut sub id { return $_[0]->{'id'}; } sub name { return $_[0]->{'name'}; } sub description { return $_[0]->{'description'}; } sub cc_list { return $_[0]->{'cc_list'}; } sub target_type { return $_[0]->{'target_type'} eq 'b' ? 'bug' : 'attachment'; } sub is_active { return $_[0]->{'is_active'}; } sub is_requestable { return $_[0]->{'is_requestable'}; } sub is_requesteeble { return $_[0]->{'is_requesteeble'}; } sub is_multiplicable { return $_[0]->{'is_multiplicable'}; } sub sortkey { return $_[0]->{'sortkey'}; } sub request_group_id { return $_[0]->{'request_group_id'}; } sub grant_group_id { return $_[0]->{'grant_group_id'}; } ################################ # Validators ################################ sub _check_name { my ($invocant, $name) = @_; $name = trim($name); ($name && $name !~ /[\s,]/ && length($name) <= 50) || ThrowUserError('flag_type_name_invalid', { name => $name }); return $name; } sub _check_description { my ($invocant, $desc) = @_; $desc = trim($desc); $desc || ThrowUserError('flag_type_description_invalid'); return $desc; } sub _check_cc_list { my ($invocant, $cc_list) = @_; length($cc_list) <= 200 || ThrowUserError('flag_type_cc_list_invalid', { cc_list => $cc_list }); my @addresses = split(/[,\s]+/, $cc_list); my $addr_spec = $Email::Address::addr_spec; # We do not call check_email_syntax() because these addresses do not # require to match 'emailregexp' and do not depend on 'emailsuffix'. foreach my $address (@addresses) { ($address !~ /\P{ASCII}/ && $address =~ /^$addr_spec$/) || ThrowUserError('illegal_email_address', {addr => $address, default => 1}); } return $cc_list; } sub _check_target_type { my ($invocant, $target_type) = @_; ($target_type eq 'bug' || $target_type eq 'attachment') || ThrowCodeError('flag_type_target_type_invalid', { target_type => $target_type }); return $target_type; } sub _check_sortkey { my ($invocant, $sortkey) = @_; (detaint_natural($sortkey) && $sortkey <= MAX_SMALLINT) || ThrowUserError('flag_type_sortkey_invalid', { sortkey => $sortkey }); return $sortkey; } sub _check_group { my ($invocant, $group) = @_; return unless $group; trick_taint($group); $group = Bugzilla::Group->check($group); return $group->id; } ############################### #### Methods #### ############################### sub set_name { $_[0]->set('name', $_[1]); } sub set_description { $_[0]->set('description', $_[1]); } sub set_cc_list { $_[0]->set('cc_list', $_[1]); } sub set_sortkey { $_[0]->set('sortkey', $_[1]); } sub set_is_active { $_[0]->set('is_active', $_[1]); } sub set_is_requestable { $_[0]->set('is_requestable', $_[1]); } sub set_is_specifically_requestable { $_[0]->set('is_requesteeble', $_[1]); } sub set_is_multiplicable { $_[0]->set('is_multiplicable', $_[1]); } sub set_grant_group { $_[0]->set('grant_group_id', $_[1]); } sub set_request_group { $_[0]->set('request_group_id', $_[1]); } sub set_clusions { my ($self, $list) = @_; my $user = Bugzilla->user; my %products; my $params = {}; # If the user has editcomponents privs, then we only need to make sure # that the product exists. if ($user->in_group('editcomponents')) { $params->{allow_inaccessible} = 1; } foreach my $category (keys %$list) { my %clusions; my %clusions_as_hash; foreach my $prod_comp (@{$list->{$category} || []}) { my ($prod_id, $comp_id) = split(':', $prod_comp); my $prod_name = '__Any__'; my $comp_name = '__Any__'; # Does the product exist? if ($prod_id) { detaint_natural($prod_id) || ThrowCodeError('param_must_be_numeric', { function => 'Bugzilla::FlagType::set_clusions' }); if (!$products{$prod_id}) { $params->{id} = $prod_id; $products{$prod_id} = Bugzilla::Product->check($params); $user->in_group('editcomponents', $prod_id) || ThrowUserError('product_access_denied', $params); } $prod_name = $products{$prod_id}->name; # Does the component belong to this product? if ($comp_id) { detaint_natural($comp_id) || ThrowCodeError('param_must_be_numeric', { function => 'Bugzilla::FlagType::set_clusions' }); my ($component) = grep { $_->id == $comp_id } @{$products{$prod_id}->components} or ThrowUserError('product_unknown_component', { product => $prod_name, comp_id => $comp_id }); $comp_name = $component->name; } else { $comp_id = 0; } } else { $prod_id = 0; $comp_id = 0; } $clusions{"$prod_name:$comp_name"} = "$prod_id:$comp_id"; $clusions_as_hash{$prod_id}->{$comp_id} = 1; } $self->{$category} = \%clusions; $self->{"${category}_as_hash"} = \%clusions_as_hash; $self->{"_update_$category"} = 1; } } =pod =over =item C Returns a reference to an array of users who have permission to grant this flag type. The arrays are populated with hashrefs containing the login, identity and visibility of users. =item C Returns the group (as a Bugzilla::Group object) in which a user must be in order to grant or deny a request. =item C Returns the group (as a Bugzilla::Group object) in which a user must be in order to request or clear a flag. =item C Returns the number of flags belonging to the flagtype. =item C Return a hash of product/component IDs and names explicitly associated with the flagtype. =item C Return a hash of product/component IDs and names explicitly excluded from the flagtype. =back =cut sub grant_list { my $self = shift; require Bugzilla::User; my @custusers; my @allusers = @{Bugzilla->user->get_userlist}; foreach my $user (@allusers) { my $user_obj = new Bugzilla::User({name => $user->{login}}); push(@custusers, $user) if $user_obj->can_set_flag($self); } return \@custusers; } sub grant_group { my $self = shift; if (!defined $self->{'grant_group'} && $self->{'grant_group_id'}) { $self->{'grant_group'} = new Bugzilla::Group($self->{'grant_group_id'}); } return $self->{'grant_group'}; } sub request_group { my $self = shift; if (!defined $self->{'request_group'} && $self->{'request_group_id'}) { $self->{'request_group'} = new Bugzilla::Group($self->{'request_group_id'}); } return $self->{'request_group'}; } sub flag_count { my $self = shift; if (!defined $self->{'flag_count'}) { $self->{'flag_count'} = Bugzilla->dbh->selectrow_array('SELECT COUNT(*) FROM flags WHERE type_id = ?', undef, $self->{'id'}); } return $self->{'flag_count'}; } sub inclusions { my $self = shift; if (!defined $self->{inclusions}) { ($self->{inclusions}, $self->{inclusions_as_hash}) = get_clusions($self->id, 'in'); } return $self->{inclusions}; } sub inclusions_as_hash { my $self = shift; $self->inclusions unless defined $self->{inclusions_as_hash}; return $self->{inclusions_as_hash}; } sub exclusions { my $self = shift; if (!defined $self->{exclusions}) { ($self->{exclusions}, $self->{exclusions_as_hash}) = get_clusions($self->id, 'ex'); } return $self->{exclusions}; } sub exclusions_as_hash { my $self = shift; $self->exclusions unless defined $self->{exclusions_as_hash}; return $self->{exclusions_as_hash}; } ###################################################################### # Public Functions ###################################################################### =pod =head1 PUBLIC FUNCTIONS/METHODS =over =item C Return a hash of product/component IDs and names associated with the flagtype: $clusions{'product_name:component_name'} = "product_ID:component_ID" =back =cut sub get_clusions { my ($id, $type) = @_; my $dbh = Bugzilla->dbh; my $list = $dbh->selectall_arrayref("SELECT products.id, products.name, components.id, components.name FROM flagtypes INNER JOIN flag${type}clusions ON flag${type}clusions.type_id = flagtypes.id LEFT JOIN products ON flag${type}clusions.product_id = products.id LEFT JOIN components ON flag${type}clusions.component_id = components.id WHERE flagtypes.id = ?", undef, $id); my (%clusions, %clusions_as_hash); foreach my $data (@$list) { my ($product_id, $product_name, $component_id, $component_name) = @$data; $product_id ||= 0; $product_name ||= "__Any__"; $component_id ||= 0; $component_name ||= "__Any__"; $clusions{"$product_name:$component_name"} = "$product_id:$component_id"; $clusions_as_hash{$product_id}->{$component_id} = 1; } return (\%clusions, \%clusions_as_hash); } =pod =over =item C Queries the database for flag types matching the given criteria and returns a list of matching flagtype objects. =back =cut sub match { my ($criteria) = @_; my $dbh = Bugzilla->dbh; # Depending on the criteria, we may have to append additional tables. my $tables = [DB_TABLE]; my @criteria = sqlify_criteria($criteria, $tables); $tables = join(' ', @$tables); $criteria = join(' AND ', @criteria); my $flagtype_ids = $dbh->selectcol_arrayref("SELECT id FROM $tables WHERE $criteria"); return Bugzilla::FlagType->new_from_list($flagtype_ids); } =pod =over =item C Returns the total number of flag types matching the given criteria. =back =cut sub count { my ($criteria) = @_; my $dbh = Bugzilla->dbh; # Depending on the criteria, we may have to append additional tables. my $tables = [DB_TABLE]; my @criteria = sqlify_criteria($criteria, $tables); $tables = join(' ', @$tables); $criteria = join(' AND ', @criteria); my $count = $dbh->selectrow_array("SELECT COUNT(flagtypes.id) FROM $tables WHERE $criteria"); return $count; } ###################################################################### # Private Functions ###################################################################### # Converts a hash of criteria into a list of SQL criteria. # $criteria is a reference to the criteria (field => value), # $tables is a reference to an array of tables being accessed # by the query. sub sqlify_criteria { my ($criteria, $tables) = @_; my $dbh = Bugzilla->dbh; # the generated list of SQL criteria; "1=1" is a clever way of making sure # there's something in the list so calling code doesn't have to check list # size before building a WHERE clause out of it my @criteria = ("1=1"); if ($criteria->{name}) { if (ref($criteria->{name}) eq 'ARRAY') { my @names = map { $dbh->quote($_) } @{$criteria->{name}}; # Detaint data as we have quoted it. foreach my $name (@names) { trick_taint($name); } push @criteria, $dbh->sql_in('flagtypes.name', \@names); } else { my $name = $dbh->quote($criteria->{name}); trick_taint($name); # Detaint data as we have quoted it. push(@criteria, "flagtypes.name = $name"); } } if ($criteria->{target_type}) { # The target type is stored in the database as a one-character string # ("a" for attachment and "b" for bug), but this function takes complete # names ("attachment" and "bug") for clarity, so we must convert them. my $target_type = $criteria->{target_type} eq 'bug'? 'b' : 'a'; push(@criteria, "flagtypes.target_type = '$target_type'"); } if (exists($criteria->{is_active})) { my $is_active = $criteria->{is_active} ? "1" : "0"; push(@criteria, "flagtypes.is_active = $is_active"); } if ($criteria->{product_id}) { my $product_id = $criteria->{product_id}; detaint_natural($product_id) || ThrowCodeError('bad_arg', { argument => 'product_id', function => 'Bugzilla::FlagType::sqlify_criteria' }); # Add inclusions to the query, which simply involves joining the table # by flag type ID and target product/component. push(@$tables, "INNER JOIN flaginclusions AS i ON flagtypes.id = i.type_id"); push(@criteria, "(i.product_id = $product_id OR i.product_id IS NULL)"); # Add exclusions to the query, which is more complicated. First of all, # we do a LEFT JOIN so we don't miss flag types with no exclusions. # Then, as with inclusions, we join on flag type ID and target product/ # component. However, since we want flag types that *aren't* on the # exclusions list, we add a WHERE criteria to use only records with # NULL exclusion type, i.e. without any exclusions. my $join_clause = "flagtypes.id = e.type_id "; my $addl_join_clause = ""; if ($criteria->{component_id}) { my $component_id = $criteria->{component_id}; detaint_natural($component_id) || ThrowCodeError('bad_arg', { argument => 'component_id', function => 'Bugzilla::FlagType::sqlify_criteria' }); push(@criteria, "(i.component_id = $component_id OR i.component_id IS NULL)"); $join_clause .= "AND (e.component_id = $component_id OR e.component_id IS NULL) "; } else { $addl_join_clause = "AND e.component_id IS NULL OR (i.component_id = e.component_id) "; } $join_clause .= "AND ((e.product_id = $product_id $addl_join_clause) OR e.product_id IS NULL)"; push(@$tables, "LEFT JOIN flagexclusions AS e ON ($join_clause)"); push(@criteria, "e.type_id IS NULL"); } if ($criteria->{group}) { my $gid = $criteria->{group}; detaint_natural($gid) || ThrowCodeError('bad_arg', { argument => 'group', function => 'Bugzilla::FlagType::sqlify_criteria' }); push(@criteria, "(flagtypes.grant_group_id = $gid " . " OR flagtypes.request_group_id = $gid)"); } return @criteria; } 1; =head1 B =over =item exclusions_as_hash =item request_group_id =item set_is_active =item set_is_multiplicable =item inclusions_as_hash =item set_sortkey =item grant_group_id =item set_cc_list =item set_request_group =item set_name =item set_is_specifically_requestable =item set_grant_group =item create =item set_clusions =item set_description =item set_is_requestable =item update =back