# -*- Mode: perl; indent-tabs-mode: nil -*- # # The contents of this file are subject to the Mozilla Public # License Version 1.1 (the "License"); you may not use this file # except in compliance with the License. You may obtain a copy of # the License at http://www.mozilla.org/MPL/ # # Software distributed under the License is distributed on an "AS # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or # implied. See the License for the specific language governing # rights and limitations under the License. # # The Original Code is the BMO Bugzilla Extension. # # The Initial Developer of the Original Code is Gervase Markham. # Portions created by the Initial Developer are Copyright (C) 2010 the # Initial Developer. All Rights Reserved. # # Contributor(s): # Gervase Markham # David Lawrence # Byron Jones package Bugzilla::Extension::BMO; use strict; use base qw(Bugzilla::Extension); use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Field; use Bugzilla::Group; use Bugzilla::Mailer; use Bugzilla::Product; use Bugzilla::Status; use Bugzilla::Token; use Bugzilla::Install::Filesystem; use Bugzilla::User; use Bugzilla::User::Setting; use Bugzilla::Util; use Bugzilla::Util qw(html_quote trick_taint trim datetime_from detaint_natural); use Date::Parse; use DateTime; use Encode qw(find_encoding encode_utf8); use Scalar::Util qw(blessed); use Sys::Syslog qw(:DEFAULT setlogsock); use Bugzilla::Extension::BMO::Constants; use Bugzilla::Extension::BMO::FakeBug; use Bugzilla::Extension::BMO::Data; our $VERSION = '0.1'; # # Monkey-patched methods # BEGIN { *Bugzilla::Bug::last_closed_date = \&_last_closed_date; *Bugzilla::Product::default_security_group = \&_default_security_group; *Bugzilla::Product::default_security_group_obj = \&_default_security_group_obj; *Bugzilla::Product::group_always_settable = \&_group_always_settable; *Bugzilla::check_default_product_security_group = \&_check_default_product_security_group; } sub template_before_process { my ($self, $args) = @_; my $file = $args->{'file'}; my $vars = $args->{'vars'}; $vars->{'cf_hidden_in_product'} = \&cf_hidden_in_product; if ($file =~ /^list\/list/) { # Purpose: enable correct sorting of list table # Matched to changes in list/table.html.tmpl my %db_order_column_name_map = ( 'map_components.name' => 'component', 'map_products.name' => 'product', 'map_reporter.login_name' => 'reporter', 'map_assigned_to.login_name' => 'assigned_to', 'delta_ts' => 'opendate', 'creation_ts' => 'changeddate', ); my @orderstrings = split(/,\s*/, $vars->{'order'}); # contains field names of the columns being used to sort the table. my @order_columns; foreach my $o (@orderstrings) { $o =~ s/bugs.//; $o = $db_order_column_name_map{$o} if grep($_ eq $o, keys(%db_order_column_name_map)); next if (grep($_ eq $o, @order_columns)); push(@order_columns, $o); } $vars->{'order_columns'} = \@order_columns; # fields that have a custom sortkey. (So they are correctly sorted # when using js) my @sortkey_fields = qw(bug_status resolution bug_severity priority rep_platform op_sys); my %columns_sortkey; foreach my $field (@sortkey_fields) { $columns_sortkey{$field} = _get_field_values_sort_key($field); } $columns_sortkey{'target_milestone'} = _get_field_values_sort_key('milestones'); $vars->{'columns_sortkey'} = \%columns_sortkey; } elsif ($file =~ /^bug\/create\/create[\.-](.*)/) { my $format = $1; if (!$vars->{'cloned_bug_id'}) { # Allow status whiteboard values to be bookmarked $vars->{'status_whiteboard'} = Bugzilla->cgi->param('status_whiteboard') || ""; } # Purpose: for pretty product chooser $vars->{'format'} = Bugzilla->cgi->param('format'); if ($format eq 'doc.html.tmpl') { my $versions = Bugzilla::Product->new({ name => 'Core' })->versions; $vars->{'versions'} = [ reverse @$versions ]; } } if ($file =~ /^list\/list/ || $file =~ /^bug\/create\/create[\.-]/) { # hack to allow the bug entry templates to use check_can_change_field # to see if various field values should be available to the current user. $vars->{'default'} = Bugzilla::Extension::BMO::FakeBug->new($vars->{'default'} || {}); } if ($file =~ /^attachment\/diff-header\./) { my $attachid = $vars->{attachid} ? $vars->{attachid} : $vars->{newid}; $vars->{attachment} = Bugzilla::Attachment->new({ id => $attachid, cache => 1 }) if $attachid; } } sub page_before_template { my ($self, $args) = @_; my $page = $args->{'page_id'}; my $vars = $args->{'vars'}; if ($page eq 'user_activity.html') { require Bugzilla::Extension::BMO::Reports::UserActivity; Bugzilla::Extension::BMO::Reports::UserActivity::report($vars); } elsif ($page eq 'triage_reports.html') { require Bugzilla::Extension::BMO::Reports::Triage; Bugzilla::Extension::BMO::Reports::Triage::report($vars); } elsif ($page eq 'group_admins.html') { require Bugzilla::Extension::BMO::Reports::Groups; Bugzilla::Extension::BMO::Reports::Groups::admins_report($vars); } elsif ($page eq 'group_membership.html' or $page eq 'group_membership.txt') { require Bugzilla::Extension::BMO::Reports::Groups; Bugzilla::Extension::BMO::Reports::Groups::membership_report($page, $vars); } elsif ($page eq 'group_members.html' or $page eq 'group_members.json') { require Bugzilla::Extension::BMO::Reports::Groups; Bugzilla::Extension::BMO::Reports::Groups::members_report($vars); } elsif ($page eq 'email_queue.html') { require Bugzilla::Extension::BMO::Reports::EmailQueue; Bugzilla::Extension::BMO::Reports::EmailQueue::report($vars); } elsif ($page eq 'release_tracking_report.html') { require Bugzilla::Extension::BMO::Reports::ReleaseTracking; Bugzilla::Extension::BMO::Reports::ReleaseTracking::report($vars); } elsif ($page eq 'product_security_report.html') { require Bugzilla::Extension::BMO::Reports::ProductSecurity; Bugzilla::Extension::BMO::Reports::ProductSecurity::report($vars); } elsif ($page eq 'fields.html') { # Recently global/field-descs.none.tmpl and bug/field-help.none.tmpl # were changed for better performance and are now only loaded once. # I have not found an easy way to allow our hook template to check if # it is called from pages/fields.html.tmpl. So we set a value in request_cache # that our hook template can see. Bugzilla->request_cache->{'bmo_fields_page'} = 1; } elsif ($page eq 'query_database.html') { query_database($vars); } } sub _get_field_values_sort_key { my ($field) = @_; my $dbh = Bugzilla->dbh; my $fields = $dbh->selectall_arrayref( "SELECT value, sortkey FROM $field ORDER BY sortkey, value"); my %field_values; foreach my $field (@$fields) { my ($value, $sortkey) = @$field; $field_values{$value} = $sortkey; } return \%field_values; } sub active_custom_fields { my ($self, $args) = @_; my $fields = $args->{'fields'}; my $params = $args->{'params'}; my $product = $params->{'product'}; my $component = $params->{'component'}; return if !$product; my $product_name = blessed $product ? $product->name : $product; my $component_name = blessed $component ? $component->name : $component; my @tmp_fields; foreach my $field (@$$fields) { next if cf_hidden_in_product($field->name, $product_name, $component_name); push(@tmp_fields, $field); } $$fields = \@tmp_fields; } sub cf_hidden_in_product { my ($field_name, $product_name, $component_name) = @_; # If used in buglist.cgi, we pass in one_product which is a Bugzilla::Product # elsewhere, we just pass the name of the product. $product_name = blessed($product_name) ? $product_name->name : $product_name; # Also in buglist.cgi, we pass in a list of components instead # of a single component name everywhere else. my $component_list = []; if ($component_name) { $component_list = ref $component_name ? $component_name : [ $component_name ]; } foreach my $field_re (keys %$cf_visible_in_products) { if ($field_name =~ $field_re) { # If no product given, for example more than one product # in buglist.cgi, then hide field by default return 1 if !$product_name; my $products = $cf_visible_in_products->{$field_re}; foreach my $product (keys %$products) { my $components = $products->{$product}; my $found_component = 0; if (@$components) { foreach my $component (@$components) { if (ref($component) eq 'Regexp') { if (grep($_ =~ $component, @$component_list)) { $found_component = 1; last; } } else { if (grep($_ eq $component, @$component_list)) { $found_component = 1; last; } } } } # If product matches and at at least one component matches # from component_list (if a matching component was required), # we allow the field to be seen if ($product eq $product_name && (!@$components || $found_component)) { return 0; } } return 1; } } return 0; } # Purpose: CC certain email addresses on bugmail when a bug is added or # removed from a particular group. sub bugmail_recipients { my ($self, $args) = @_; my $bug = $args->{'bug'}; my $recipients = $args->{'recipients'}; my $diffs = $args->{'diffs'}; if (@$diffs) { # Changed bug foreach my $ref (@$diffs) { my $old = $ref->{old}; my $new = $ref->{new}; my $fieldname = $ref->{field_name}; if ($fieldname eq "bug_group") { _cc_if_special_group($old, $recipients); _cc_if_special_group($new, $recipients); } } } else { # Determine if it's a new bug, or a comment without a field change my $comment_count = scalar @{$bug->comments}; if ($comment_count == 1) { # New bug foreach my $group (@{ $bug->groups_in }) { _cc_if_special_group($group->{'name'}, $recipients); } } } } sub _cc_if_special_group { my ($group, $recipients) = @_; return if !$group; if (exists $group_change_notification{$group}) { foreach my $login (@{ $group_change_notification{$group} }) { my $id = login_to_id($login); $recipients->{$id}->{+REL_CC} = Bugzilla::BugMail::BIT_DIRECT(); } } } sub _check_trusted { my ($field, $trusted, $priv_results) = @_; my $needed_group = $trusted->{'_default'} || ""; foreach my $dfield (keys %$trusted) { if ($field =~ $dfield) { $needed_group = $trusted->{$dfield}; } } if ($needed_group && !Bugzilla->user->in_group($needed_group)) { push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); } } sub _is_field_set { my $value = shift; return $value ne '---' && $value !~ /\?$/; } sub bug_check_can_change_field { my ($self, $args) = @_; my $bug = $args->{'bug'}; my $field = $args->{'field'}; my $new_value = $args->{'new_value'}; my $old_value = $args->{'old_value'}; my $priv_results = $args->{'priv_results'}; my $user = Bugzilla->user; if ($field =~ /^cf/ && !@$priv_results && $new_value ne '---') { # "other" custom field setters restrictions if (exists $other_setters->{$field}) { my $in_group = 0; foreach my $group (@{$other_setters->{$field}}) { if ($user->in_group($group, $bug->product_id)) { $in_group = 1; last; } } if (!$in_group) { push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); } } } elsif ($field eq 'resolution' && $new_value eq 'EXPIRED') { # The EXPIRED resolution should only be settable by gerv. if ($user->login ne 'gerv@mozilla.org') { push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); } } elsif ($field eq 'resolution' && $new_value eq 'FIXED') { # You need at least canconfirm to mark a bug as FIXED if (!$user->in_group('canconfirm', $bug->{'product_id'})) { push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); } } elsif ( ($field eq 'bug_status' && $old_value eq 'VERIFIED') || ($field eq 'dup_id' && $bug->status->name eq 'VERIFIED') || ($field eq 'resolution' && $bug->status->name eq 'VERIFIED') ) { # You need at least editbugs to reopen a resolved/verified bug if (!$user->in_group('editbugs', $bug->{'product_id'})) { push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); } } elsif ($user->in_group('canconfirm', $bug->{'product_id'})) { # Canconfirm is really "cantriage"; users with canconfirm can also mark # bugs as DUPLICATE, WORKSFORME, and INCOMPLETE. if ($field eq 'bug_status' && is_open_state($old_value) && !is_open_state($new_value)) { push (@$priv_results, PRIVILEGES_REQUIRED_NONE); } elsif ($field eq 'resolution' && ($new_value eq 'DUPLICATE' || $new_value eq 'WORKSFORME' || $new_value eq 'INCOMPLETE')) { push (@$priv_results, PRIVILEGES_REQUIRED_NONE); } } elsif ($field eq 'bug_status') { # Disallow reopening of bugs which have been resolved for > 1 year if (is_open_state($new_value) && !is_open_state($old_value) && $bug->resolution eq 'FIXED') { my $days_ago = DateTime->now(time_zone => Bugzilla->local_timezone); $days_ago->subtract(days => 365); my $last_closed = datetime_from($bug->last_closed_date); if ($last_closed lt $days_ago) { push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); } } } } # link up various Mozilla-specific strings sub bug_format_comment { my ($self, $args) = @_; my $regexes = $args->{'regexes'}; # link UUIDs to crash-stats # Only match if not already in an URL using the negative lookbehind (? qr/(? sub { my $args = shift; my $match = html_quote($args->{matches}->[0]); return qq{bp-$match}; } }); # link to CVE/CAN security releases push (@$regexes, { match => qr/(? sub { my $args = shift; my $match = html_quote($args->{matches}->[0]); return qq{$match}; } }); # link to svn.m.o push (@$regexes, { match => qr/\br(\d{4,})\b/, replace => sub { my $args = shift; my $match = html_quote($args->{matches}->[0]); return qq{r$match}; } }); # link bzr commit messages push (@$regexes, { match => qr/\b(Committing\sto:\sbzr\+ssh:\/\/ (?:[^\@]+\@)?(bzr\.mozilla\.org[^\n]+)\n.*?\bCommitted\s) (revision\s(\d+))/sx, replace => sub { my $args = shift; my $preamble = html_quote($args->{matches}->[0]); my $url = html_quote($args->{matches}->[1]); my $text = html_quote($args->{matches}->[2]); my $id = html_quote($args->{matches}->[3]); $url =~ s/\s+$//; $url =~ s/\/$//; return qq{$preamble$text}; } }); # link to hg.m.o # Note: for grouping in this regexp, always use non-capturing parentheses. my $hgrepos = join('|', qw!(?:releases/)?comm-[\w.]+ (?:releases/)?mozilla-[\w.]+ (?:releases/)?mobile-[\w.]+ tracemonkey tamarin-[\w.]+ camino!); push (@$regexes, { match => qr/\b(($hgrepos)\s+changeset:?\s+(?:\d+:)?([0-9a-fA-F]{12}))\b/, replace => sub { my $args = shift; my $text = html_quote($args->{matches}->[0]); my $repo = html_quote($args->{matches}->[1]); my $id = html_quote($args->{matches}->[2]); $repo = 'integration/mozilla-inbound' if $repo eq 'mozilla-inbound'; return qq{$text}; } }); } sub quicksearch_map { my ($self, $args) = @_; my $map = $args->{'map'}; foreach my $name (keys %$map) { if ($name =~ /cf_crash_signature$/) { $map->{'sig'} = $name; } } } sub object_end_of_create { my ($self, $args) = @_; if ($args->{class} eq 'Bugzilla::User') { # Add default searches to new user's footer my $dbh = Bugzilla->dbh; my $user = $args->{object}; my $sharer = Bugzilla::User->new({ name => 'nobody@mozilla.org' }) or return; my $group = Bugzilla::Group->new({ name => 'everyone' }) or return; foreach my $definition (@default_named_queries) { my ($namedquery_id) = _get_named_query($sharer->id, $group->id, $definition); $dbh->do( "INSERT INTO namedqueries_link_in_footer(namedquery_id,user_id) VALUES (?,?)", undef, $namedquery_id, $user->id ); } } } sub _get_named_query { my ($sharer_id, $group_id, $definition) = @_; my $dbh = Bugzilla->dbh; # find existing namedquery my ($namedquery_id) = $dbh->selectrow_array( "SELECT id FROM namedqueries WHERE userid=? AND name=?", undef, $sharer_id, $definition->{name} ); return $namedquery_id if $namedquery_id; # create namedquery $dbh->do( "INSERT INTO namedqueries(userid,name,query) VALUES (?,?,?)", undef, $sharer_id, $definition->{name}, $definition->{query} ); $namedquery_id = $dbh->bz_last_key(); # and share it $dbh->do( "INSERT INTO namedquery_group_map(namedquery_id,group_id) VALUES (?,?)", undef, $namedquery_id, $group_id, ); return $namedquery_id; } # Automatically CC users to bugs based on group & product sub bug_end_of_create { my ($self, $args) = @_; my $bug = $args->{'bug'}; foreach my $group_name (keys %group_auto_cc) { my $group_obj = Bugzilla::Group->new({ name => $group_name }); if ($group_obj && $bug->in_group($group_obj)) { my $ra_logins = exists $group_auto_cc{$group_name}->{$bug->product} ? $group_auto_cc{$group_name}->{$bug->product} : $group_auto_cc{$group_name}->{'_default'}; foreach my $login (@$ra_logins) { $bug->add_cc($login); } } } } # detect github pull requests and set a unique content-type sub attachment_process_data { my ($self, $args) = @_; my $attributes = $args->{attributes}; # quick checks - must be a text/plain non-patch return if $attributes->{ispatch} || $attributes->{mimetype} ne 'text/plain'; # check the attachment size, and get attachment content if it isn't too large my $data = $attributes->{data}; my $url; if (blessed($data) && blessed($data) eq 'Fh') { # filehandle my $size = -s $data; return if $size > 256; sysread($data, $url, $size); seek($data, 0, 0); } else { # string return if length($data) > 256; $url = $data; } # trim and check for the pull request url $url = trim($url); return if $url =~ /\s/; return unless $url =~ m#^https://github\.com/[^/]+/[^/]+/pull/\d+\/?$#i; # must be a valid pull-request $attributes->{mimetype} = GITHUB_PR_CONTENT_TYPE; } # redirect automatically to github urls sub attachment_view { my ($self, $args) = @_; my $attachment = $args->{attachment}; my $cgi = Bugzilla->cgi; # don't redirect if the content-type is specified explicitly return if defined $cgi->param('content_type'); # must be our github content-type return unless $attachment->contenttype eq GITHUB_PR_CONTENT_TYPE; # redirect print $cgi->redirect(trim($attachment->data)); exit; } sub install_before_final_checks { my ($self, $args) = @_; # Add product chooser setting (although it was added long ago, so add_setting # will just return every time). add_setting('product_chooser', ['pretty_product_chooser', 'full_product_chooser'], 'pretty_product_chooser'); # Migrate from 'gmail_threading' setting to 'bugmail_new_prefix' my $dbh = Bugzilla->dbh; if ($dbh->selectrow_array("SELECT 1 FROM setting WHERE name='gmail_threading'")) { $dbh->bz_start_transaction(); $dbh->do("UPDATE profile_setting SET setting_value='on-temp' WHERE setting_name='gmail_threading' AND setting_value='Off'"); $dbh->do("UPDATE profile_setting SET setting_value='off' WHERE setting_name='gmail_threading' AND setting_value='On'"); $dbh->do("UPDATE profile_setting SET setting_value='on' WHERE setting_name='gmail_threading' AND setting_value='on-temp'"); $dbh->do("UPDATE profile_setting SET setting_name='bugmail_new_prefix' WHERE setting_name='gmail_threading'"); $dbh->do("DELETE FROM setting WHERE name='gmail_threading'"); $dbh->bz_commit_transaction(); } } # Migrate old is_active stuff to new patch (is in core in 4.2), The old column # name was 'is_active', the new one is 'isactive' (no underscore). sub install_update_db { my $dbh = Bugzilla->dbh; if ($dbh->bz_column_info('milestones', 'is_active')) { $dbh->do("UPDATE milestones SET isactive = 0 WHERE is_active = 0;"); $dbh->bz_drop_column('milestones', 'is_active'); $dbh->bz_drop_column('milestones', 'is_searchable'); } } sub _last_closed_date { my ($self) = @_; my $dbh = Bugzilla->dbh; return $self->{'last_closed_date'} if defined $self->{'last_closed_date'}; my $closed_statuses = "'" . join("','", map { $_->name } closed_bug_statuses()) . "'"; my $status_field_id = get_field_id('bug_status'); $self->{'last_closed_date'} = $dbh->selectrow_array(" SELECT bugs_activity.bug_when FROM bugs_activity WHERE bugs_activity.fieldid = ? AND bugs_activity.added IN ($closed_statuses) AND bugs_activity.bug_id = ? ORDER BY bugs_activity.bug_when DESC " . $dbh->sql_limit(1), undef, $status_field_id, $self->id ); return $self->{'last_closed_date'}; } sub field_end_of_create { my ($self, $args) = @_; my $field = $args->{'field'}; # email mozilla's DBAs so they can update the grants for metrics # this really should create a bug in mozilla.org/Server Operations: Database if (Bugzilla->params->{'urlbase'} ne 'https://bugzilla.mozilla.org/') { return; } if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) { print "Emailing notification to infra-dbnotices\@mozilla.com\n"; } my $name = $field->name; my @message; push @message, 'To: infra-dbnotices@mozilla.com'; push @message, "Subject: custom field '$name' added to bugzilla.mozilla.org"; push @message, 'From: ' . Bugzilla->params->{mailfrom}; push @message, ''; push @message, "The custom field '$name' has been added to the BMO database."; push @message, ''; push @message, 'Please run the following on bugzilla1.db.scl3.mozilla.com:'; push @message, " GRANT SELECT ON `bugs`.`$name` TO 'metrics'\@'10.22.70.20_';"; push @message, " GRANT SELECT ($name) ON `bugs`.`bugs` TO 'metrics'\@'10.22.70.20_';"; push @message, " GRANT SELECT ON `bugs`.`$name` TO 'metrics'\@'10.22.70.21_';"; push @message, " GRANT SELECT ($name) ON `bugs`.`bugs` TO 'metrics'\@'10.22.70.21_';"; push @message, ''; MessageToMTA(join("\n", @message)); } sub webservice { my ($self, $args) = @_; my $dispatch = $args->{dispatch}; $dispatch->{BMO} = "Bugzilla::Extension::BMO::WebService"; } our $search_content_matches; BEGIN { $search_content_matches = \&Bugzilla::Search::_content_matches; } sub search_operator_field_override { my ($self, $args) = @_; my $search = $args->{'search'}; my $operators = $args->{'operators'}; my $cgi = Bugzilla->cgi; my @comments = $cgi->param('comments'); my $exclude_comments = scalar(@comments) && !grep { $_ eq '1' } @comments; if ($cgi->param('query_format') && $cgi->param('query_format') eq 'specific' && $exclude_comments ) { # use the non-comment operator $operators->{'content'}->{matches} = \&_short_desc_matches; $operators->{'content'}->{notmatches} = \&_short_desc_matches; } else { # restore default content operator $operators->{'content'}->{matches} = $search_content_matches; $operators->{'content'}->{notmatches} = $search_content_matches; } } sub _short_desc_matches { # copy of Bugzilla::Search::_content_matches with comment searching removed my ($self, $args) = @_; my ($chart_id, $joins, $fields, $operator, $value) = @$args{qw(chart_id joins fields operator value)}; my $dbh = Bugzilla->dbh; # Add the fulltext table to the query so we can search on it. my $table = "bugs_fulltext_$chart_id"; push(@$joins, { table => 'bugs_fulltext', as => $table }); # Create search terms to add to the SELECT and WHERE clauses. my ($term, $rterm) = $dbh->sql_fulltext_search("$table.short_desc", $value, 2); $rterm = $term if !$rterm; # The term to use in the WHERE clause. if ($operator =~ /not/i) { $term = "NOT($term)"; } $args->{term} = $term; my $current = $self->COLUMNS->{'relevance'}->{name}; $current = $current ? "$current + " : ''; # For NOT searches, we just add 0 to the relevance. my $select_term = $operator =~ /not/ ? 0 : "($current$rterm)"; $self->COLUMNS->{'relevance'}->{name} = $select_term; } sub mailer_before_send { my ($self, $args) = @_; my $email = $args->{email}; _log_sent_email($email); # see bug 844724 if ($email->header('to') && $email->header('to') eq 'sync-1@bugzilla.tld') { $email->header_set('to', 'mei.kong@tcl.com'); } # attachments disabled, see bug 714488 return; # If email is a request for a review, add the attachment itself # to the email as an attachment. Attachment must be content type # text/plain and below a certain size. Otherwise the email already # contain a link to the attachment. if ($email && $email->header('X-Bugzilla-Type') eq 'request' && ($email->header('X-Bugzilla-Flag-Requestee') && $email->header('X-Bugzilla-Flag-Requestee') eq $email->header('to'))) { my $body = $email->body; if (my ($attach_id) = $body =~ /Attachment\s+(\d+)\s*:/) { my $attachment = Bugzilla::Attachment->new($attach_id); if ($attachment && $attachment->ispatch && $attachment->contenttype eq 'text/plain' && $attachment->linecount && $attachment->linecount < REQUEST_MAX_ATTACH_LINES) { # Don't send a charset header with attachments, as they might # not be UTF-8, unless we can properly detect it. my $charset; if (Bugzilla->feature('detect_charset')) { my $encoding = detect_encoding($attachment->data); if ($encoding) { $charset = find_encoding($encoding)->mime_name; } } my $attachment_part = Email::MIME->create( attributes => { content_type => $attachment->contenttype, filename => $attachment->filename, disposition => "attachment", }, body => $attachment->data, ); $attachment_part->charset_set($charset) if $charset; $email->parts_add([ $attachment_part ]); } } } } # Log a summary of bugmail sent to the syslog, for auditing and monitoring sub _log_sent_email { my $email = shift; my $recipient = $email->header('to'); return unless $recipient; my $subject = $email->header('Subject'); my $bug_id = $email->header('X-Bugzilla-ID'); if (!$bug_id && $subject =~ /[\[\(]Bug (\d+)/i) { $bug_id = $1; } $bug_id = $bug_id ? "bug-$bug_id" : '-'; my $message_type; my $type = $email->header('X-Bugzilla-Type'); my $reason = $email->header('X-Bugzilla-Reason'); if ($type eq 'whine' || $type eq 'request' || $type eq 'admin') { $message_type = $type; } elsif ($reason && $reason ne 'None') { $message_type = $reason; } else { $message_type = $email->header('X-Bugzilla-Watch-Reason'); } $message_type ||= $type || '?'; $subject =~ s/[\[\(]Bug \d+[\]\)]\s*//; openlog('apache', 'cons,pid', 'local4'); syslog('notice', encode_utf8("[bugmail] $recipient ($message_type) $bug_id $subject")); closelog(); } sub post_bug_after_creation { my ($self, $args) = @_; my $vars = $args->{vars}; my $bug = $vars->{bug}; if (Bugzilla->input_params->{format} && Bugzilla->input_params->{format} eq 'employee-incident' && $bug->component eq 'Server Operations: Desktop Issues') { my $error_mode_cache = Bugzilla->error_mode; Bugzilla->error_mode(ERROR_MODE_DIE); my $template = Bugzilla->template; my $cgi = Bugzilla->cgi; my ($investigate_bug, $ssh_key_bug); my $old_user = Bugzilla->user; eval { Bugzilla->set_user(Bugzilla::User->new({ name => 'nobody@mozilla.org' })); my $new_user = Bugzilla->user; # HACK: User needs to be in the editbugs and primary bug's group to allow # setting of dependencies. $new_user->{'groups'} = [ Bugzilla::Group->new({ name => 'editbugs' }), Bugzilla::Group->new({ name => 'infra' }), Bugzilla::Group->new({ name => 'infrasec' }) ]; my $recipients = { changer => $new_user }; $vars->{original_reporter} = $old_user; my $comment; $cgi->param('display_action', ''); $template->process('bug/create/comment-employee-incident.txt.tmpl', $vars, \$comment) || ThrowTemplateError($template->error()); $investigate_bug = Bugzilla::Bug->create({ short_desc => 'Investigate Lost Device', product => 'mozilla.org', component => 'Security Assurance: Incident', status_whiteboard => '[infrasec:incident]', bug_severity => 'critical', cc => [ 'mcoates@mozilla.com', 'jstevensen@mozilla.com' ], groups => [ 'infrasec' ], comment => $comment, op_sys => 'All', rep_platform => 'All', version => 'other', dependson => $bug->bug_id, }); $bug->set_all({ blocked => { add => [ $investigate_bug->bug_id ] }}); Bugzilla::BugMail::Send($investigate_bug->id, $recipients); Bugzilla->set_user($old_user); $vars->{original_reporter} = ''; $comment = ''; $cgi->param('display_action', 'ssh'); $template->process('bug/create/comment-employee-incident.txt.tmpl', $vars, \$comment) || ThrowTemplateError($template->error()); $ssh_key_bug = Bugzilla::Bug->create({ short_desc => 'Disable/Regenerate SSH Key', product => $bug->product, component => $bug->component, bug_severity => 'critical', cc => $bug->cc, groups => [ map { $_->{name} } @{ $bug->groups } ], comment => $comment, op_sys => 'All', rep_platform => 'All', version => 'other', dependson => $bug->bug_id, }); $bug->set_all({ blocked => { add => [ $ssh_key_bug->bug_id ] }}); Bugzilla::BugMail::Send($ssh_key_bug->id, $recipients); }; my $error = $@; Bugzilla->set_user($old_user); Bugzilla->error_mode($error_mode_cache); if ($error || !$investigate_bug || !$ssh_key_bug) { warn "Failed to create additional employee-incident bug: $error" if $error; $vars->{'message'} = 'employee_incident_creation_failed'; } } } sub buglist_columns { my ($self, $args) = @_; my $columns = $args->{columns}; $columns->{'cc_count'} = { name => '(SELECT COUNT(*) FROM cc WHERE cc.bug_id = bugs.bug_id)', title => 'CC Count', }; $columns->{'dupe_count'} = { name => '(SELECT COUNT(*) FROM duplicates WHERE duplicates.dupe_of = bugs.bug_id)', title => 'Duplicate Count', }; } sub enter_bug_start { my ($self, $args) = @_; # if configured with create_bug_formats, force users into a custom bug # format (can be overridden with a __standard__ format) my $cgi = Bugzilla->cgi; if ($cgi->param('format') && $cgi->param('format') eq '__standard__') { $cgi->delete('format'); } elsif (my $format = forced_format($cgi->param('product'))) { $cgi->param('format', $format); } } sub forced_format { # note: this is also called from the guided bug entry extension my ($product) = @_; return undef unless defined $product; # always work on the correct product name $product = Bugzilla::Product->new({ name => $product, cache => 1 }) unless blessed($product); return undef unless $product; # check for a forced-format entry my $forced = $create_bug_formats{$product->name} || return; # should this user be included? my $user = Bugzilla->user; my $include = ref($forced->{include}) ? $forced->{include} : [ $forced->{include} ]; foreach my $inc (@$include) { return $forced->{format} if $user->in_group($inc); } return undef; } sub query_database { my ($vars) = @_; # validate group membership my $user = Bugzilla->user; $user->in_group('query_database') || ThrowUserError('auth_failure', { group => 'query_database', action => 'access', object => 'query_database' }); # read query my $input = Bugzilla->input_params; my $query = $input->{query}; $vars->{query} = $query; if ($query) { trick_taint($query); $vars->{executed} = 1; # add limit if missing if ($query !~ /\sLIMIT\s+\d+\s*$/si) { $query .= ' LIMIT 1000'; $vars->{query} = $query; } # log query setlogsock('unix'); openlog('apache', 'cons', 'pid', 'local4'); syslog('notice', sprintf("[db_query] %s %s", $user->login, $query)); closelog(); # connect to database and execute # switching to the shadow db gives us a read-only connection my $dbh = Bugzilla->switch_to_shadow_db(); my $sth; eval { $sth = $dbh->prepare($query); $sth->execute(); }; if ($@) { $vars->{sql_error} = $@; return; } # build result my $columns = $sth->{NAME}; my $rows; while (my @row = $sth->fetchrow_array) { push @$rows, \@row; } # return results $vars->{columns} = $columns; $vars->{rows} = $rows; } } # you can always file bugs into a product's default security group, as well as # into any of the groups in @always_fileable_groups sub _group_always_settable { my ($self, $group) = @_; return $group->name eq $self->default_security_group || ((grep { $_ eq $group->name } @always_fileable_groups) ? 1 : 0); } sub _default_security_group { my ($self) = @_; return exists $product_sec_groups{$self->name} ? $product_sec_groups{$self->name} : $product_sec_groups{_default}; } sub _default_security_group_obj { my ($self) = @_; return unless my $group_name = $self->default_security_group; return Bugzilla::Group->new({ name => $group_name, cache => 1 }) } # called from the verify version, component, and group page. # if we're making a group invalid, stuff the default group into the cgi param # to make it checked by default. sub _check_default_product_security_group { my ($self, $product, $invalid_groups, $optional_group_controls) = @_; return unless my $group = $product->default_security_group_obj; if (@$invalid_groups) { my $cgi = Bugzilla->cgi; my @groups = $cgi->param('groups'); push @groups, $group->name unless grep { $_ eq $group->name } @groups; $cgi->param('groups', @groups); } } sub install_filesystem { my ($self, $args) = @_; my $files = $args->{files}; my $extensions_dir = bz_locations()->{extensionsdir}; $files->{"$extensions_dir/BMO/bin/migrate-github-pull-requests.pl"} = { perms => Bugzilla::Install::Filesystem::OWNER_EXECUTE }; } __PACKAGE__->NAME;