diff options
author | Dave Lawrence <dlawrence@mozilla.com> | 2012-03-28 23:23:41 +0200 |
---|---|---|
committer | Dave Lawrence <dlawrence@mozilla.com> | 2012-03-28 23:23:41 +0200 |
commit | 2838dad07581a783cfb4bcf32a2c003e22cb13e5 (patch) | |
tree | c2f04939781d4942d0d39fff6c02ef908b9328ae /extensions/TellUsMore/lib | |
parent | a4c234e96660489c24ec68c08d4d66f3fe1b1e19 (diff) | |
download | bugzilla-2838dad07581a783cfb4bcf32a2c003e22cb13e5.tar.gz bugzilla-2838dad07581a783cfb4bcf32a2c003e22cb13e5.tar.xz |
Bug 678146: Tell-Us-More extension
Diffstat (limited to 'extensions/TellUsMore/lib')
-rw-r--r-- | extensions/TellUsMore/lib/Constants.pm | 89 | ||||
-rw-r--r-- | extensions/TellUsMore/lib/Process.pm | 263 | ||||
-rw-r--r-- | extensions/TellUsMore/lib/VersionMirror.pm | 207 | ||||
-rw-r--r-- | extensions/TellUsMore/lib/WebService.pm | 259 |
4 files changed, 818 insertions, 0 deletions
diff --git a/extensions/TellUsMore/lib/Constants.pm b/extensions/TellUsMore/lib/Constants.pm new file mode 100644 index 000000000..110146ef6 --- /dev/null +++ b/extensions/TellUsMore/lib/Constants.pm @@ -0,0 +1,89 @@ +# 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::Extension::TellUsMore::Constants; + +use strict; +use base qw(Exporter); + +our @EXPORT = qw( + TELL_US_MORE_LOGIN + + MAX_ATTACHMENT_COUNT + MAX_ATTACHMENT_SIZE + + MAX_REPORTS_PER_MINUTE + + TARGET_PRODUCT + SECURITY_GROUP + + DEFAULT_VERSION + DEFAULT_COMPONENT + + MANDATORY_BUG_FIELDS + OPTIONAL_BUG_FIELDS + + MANDATORY_ATTACH_FIELDS + OPTIONAL_ATTACH_FIELDS + + TOKEN_EXPIRY_DAYS + + VERSION_SOURCE_PRODUCTS + VERSION_TARGET_PRODUCT + + RESULT_URL_SUCCESS + RESULT_URL_FAILURE +); + +use constant TELL_US_MORE_LOGIN => 'tellusmore@input.bugs'; + +use constant MAX_ATTACHMENT_COUNT => 2; +use constant MAX_ATTACHMENT_SIZE => 512; # kilobytes + +use constant MAX_REPORTS_PER_MINUTE => 2; + +use constant TARGET_PRODUCT => 'Untriaged Bugs'; +use constant SECURITY_GROUP => 'core-security'; + +use constant DEFAULT_VERSION => 'unspecified'; +use constant DEFAULT_COMPONENT => 'General'; + +use constant MANDATORY_BUG_FIELDS => qw( + creator + description + product + summary + user_agent +); + +use constant OPTIONAL_BUG_FIELDS => qw( + attachments + creator_name + restricted + url + version +); + +use constant MANDATORY_ATTACH_FIELDS => qw( + filename + content_type + content +); + +use constant OPTIONAL_ATTACH_FIELDS => qw( + description +); + +use constant TOKEN_EXPIRY_DAYS => 7; + +use constant VERSION_SOURCE_PRODUCTS => ('Firefox', 'Fennec'); +use constant VERSION_TARGET_PRODUCT => 'Untriaged Bugs'; + +use constant RESULT_URL_SUCCESS => 'http://input.mozilla.org/bug/thanks/?bug_id=%s&is_new_user=%s'; +use constant RESULT_URL_FAILURE => 'http://input.mozilla.org/bug/thanks/?error=%s'; + +1; diff --git a/extensions/TellUsMore/lib/Process.pm b/extensions/TellUsMore/lib/Process.pm new file mode 100644 index 000000000..a73866468 --- /dev/null +++ b/extensions/TellUsMore/lib/Process.pm @@ -0,0 +1,263 @@ +# 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::Extension::TellUsMore::Process; + +use strict; +use warnings; + +use Bugzilla::Bug; +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Hook; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Util; +use Bugzilla::Version; + +use Bugzilla::Extension::TellUsMore::Constants; + +use Data::Dumper; +use File::Basename; +use MIME::Base64; +use Safe; + +sub new { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my $object = {}; + bless($object, $class); + return $object; +} + +sub execute { + my ($self, $token) = @_; + my $dbh = Bugzilla->dbh; + + my ($bug, $user, $is_new_user); + Bugzilla->error_mode(ERROR_MODE_DIE); + eval { + $self->_delete_stale_issues(); + my ($mail, $params) = $self->_deserialise_token($token); + + $dbh->bz_start_transaction(); + + $self->_fix_invalid_params($params); + + ($user, $is_new_user) = $self->_get_user($mail, $params); + + $bug = $self->_create_bug($user, $params); + $self->_post_bug_hook($bug); + + $self->_delete_token($token); + $dbh->bz_commit_transaction(); + + $self->_send_mail($bug, $user); + }; + $self->{error} = $@; + Bugzilla->error_mode(ERROR_MODE_WEBPAGE); + return $self->{error} ? undef : ($bug, $is_new_user); +} + +sub error { + my ($self) = @_; + return $self->{error}; +} + +sub _delete_stale_issues { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + # delete issues older than TOKEN_EXPIRY_DAYS + + $dbh->do(" + DELETE FROM tell_us_more + WHERE creation_ts < NOW() - " . + $dbh->sql_interval(TOKEN_EXPIRY_DAYS, 'DAY') + ); +} + +sub _deserialise_token { + my ($self, $token) = @_; + my $dbh = Bugzilla->dbh; + + # validate token + + trick_taint($token); + my ($mail, $params) = $dbh->selectrow_array( + "SELECT mail,content FROM tell_us_more WHERE token=?", + undef, $token + ); + ThrowUserError('token_does_not_exist') unless $mail; + + # deserialise, return ($mail, $params) + + my $compartment = Safe->new(); + $compartment->reval($params) + || ThrowUserError('token_does_not_exist'); + $params = ${$compartment->varglob('VAR1')}; + + return ($mail, $params); +} + +sub _fix_invalid_params { + my ($self, $params) = @_; + + # silently adjust any params which are no longer valid + # so we don't lose the submission + + my $product = Bugzilla::Product->new({ name => TARGET_PRODUCT }) + || ThrowUserError('invalid_product_name', { product => TARGET_PRODUCT }); + + # component --> general + + my $component = Bugzilla::Component->new({ product => $product, name => $params->{component} }) + || Bugzilla::Component->new({ product => $product, name => DEFAULT_COMPONENT }) + || ThrowUserError('tum_invalid_component', { product => TARGET_PRODUCT, name => DEFAULT_COMPONENT }); + $params->{component} = $component->name; + + # version --> unspecified + + my $version = Bugzilla::Version->new({ product => $product, name => $params->{version} }) + || Bugzilla::Version->new({ product => $product, name => DEFAULT_VERSION }); + $params->{version} = $version->name; +} + +sub _get_user { + my ($self, $mail, $params) = @_; + + # return existing bmo user + + my $user = Bugzilla::User->new({ name => $mail }); + return ($user, 0) if $user; + + # or create new user + + $user = Bugzilla::User->create({ + login_name => $mail, + cryptpassword => '*', + realname => $params->{creator_name}, + }); + return ($user, 1); +} + +sub _create_bug { + my ($self, $user, $params) = @_; + my $template = Bugzilla->template; + my $vars = {}; + + # login as the user + + Bugzilla->set_user($user); + + # create the bug + + my $create = { + product => $params->{product}, + component => $params->{component}, + short_desc => $params->{summary}, + comment => $params->{description}, + version => $params->{version}, + rep_platform => $params->{rep_platform}, + op_sys => $params->{op_sys}, + bug_severity => $params->{bug_severity}, + priority => $params->{priority}, + bug_file_loc => $params->{bug_file_loc}, + }; + if ($params->{group}) { + $create->{groups} = [ $params->{group} ]; + }; + + my $bug = Bugzilla::Bug->create($create); + + # add attachments + + foreach my $attachment (@{$params->{attachments}}) { + $self->_add_attachment($bug, $attachment); + } + if (scalar @{$params->{attachments}}) { + $bug->update(); + } + + return $bug; +} + +sub _add_attachment { + my ($self, $bug, $params) = @_; + my $dbh = Bugzilla->dbh; + + # init + + my $timestamp = $dbh->selectrow_array('SELECT creation_ts FROM bugs WHERE bug_id=?', undef, $bug->bug_id); + my $data = decode_base64($params->{content}); + + my $description; + if ($params->{description}) { + $description = $params->{description}; + } else { + $description = $params->{filename}; + $description =~ s/\\/\//g; + $description = basename($description); + } + + # trigger content-type auto detection + + Bugzilla->input_params->{'contenttypemethod'} = 'autodetect'; + + # add attachment + + my $attachment = Bugzilla::Attachment->create({ + bug => $bug, + creation_ts => $timestamp, + data => $data, + description => $description, + filename => $params->{filename}, + mimetype => $params->{content_type}, + }); + + # add comment + + $bug->add_comment('', { + isprivate => 0, + type => CMT_ATTACHMENT_CREATED, + extra_data => $attachment->id, + }); +} + +sub _post_bug_hook { + my ($self, $bug) = @_; + + # trigger post_bug_after_creation hook + + my $vars = { + id => $bug->bug_id, + bug => $bug, + }; + Bugzilla::Hook::process('post_bug_after_creation', { vars => $vars }); +} + +sub _send_mail { + my ($self, $bug, $user) = @_; + + # send new-bug email + + Bugzilla::BugMail::Send($bug->bug_id, { changer => $user }); +} + +sub _delete_token { + my ($self, $token) = @_; + my $dbh = Bugzilla->dbh; + + # delete token + + trick_taint($token); + $dbh->do('DELETE FROM tell_us_more WHERE token=?', undef, $token); +} + +1; + diff --git a/extensions/TellUsMore/lib/VersionMirror.pm b/extensions/TellUsMore/lib/VersionMirror.pm new file mode 100644 index 000000000..24c645d91 --- /dev/null +++ b/extensions/TellUsMore/lib/VersionMirror.pm @@ -0,0 +1,207 @@ +# 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::Extension::TellUsMore::VersionMirror; + +use strict; +use base qw(Exporter); +our @EXPORT_OK = qw(update_versions); + +use Bugzilla::Constants; +use Bugzilla::Product; + +use Bugzilla::Extension::TellUsMore::Constants; + +sub new { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my $object = {}; + bless($object, $class); + return $object; +} + +sub created { + my ($self, $created) = @_; + return unless $self->_should_process($created); + + my $version = $self->_get($created); + if ($version) { + # version already exists, reactivate if required + if (!$version->is_active) { + $version->set_is_active(1); + $version->update(); + } + } else { + # create version + $self->_create_version($created->name); + } +} + +sub updated { + my ($self, $old, $new) = @_; + return unless $self->_should_process($old); + + my $version = $self->_get($old) + or return; + + my $updated = 0; + if ($version->name ne $new->name) { + if ($version->bug_count) { + # version renamed, but old name has bugs + # create a new version to avoid touching bugs + $self->_create_version($new->name); + return; + } else { + # renaming the version is safe as it is unused + $version->set_name($new->name); + $updated = 1; + } + } + + if ($version->is_active != $new->is_active) { + if ($new->is_active) { + # activating, always safe + $version->set_is_active(1); + $updated = 1; + } else { + # can only deactivate when all source products agree + my $active = 0; + foreach my $product ($self->_sources) { + foreach my $product_version (@{$product->versions}) { + next unless _version_eq($product_version, $new); + if ($product_version->is_active) { + $active = 1; + last; + } + } + last if $active; + } + if (!$active) { + $version->set_is_active(0); + $updated = 1; + } + } + } + + if ($updated) { + $version->update(); + } +} + +sub deleted { + my ($self, $deleted) = @_; + return unless $self->_should_process($deleted); + + my $version = $self->_get($deleted) + or return; + + # can only delete when all source products agreee + foreach my $product ($self->_sources) { + next if $product->name eq $deleted->product->name; + if (grep { _version_eq($_, $version) } @{$product->versions}) { + return; + } + } + + if ($version->bug_count) { + # if there's active bugs, deactivate instead of deleting + $version->set_is_active(0); + $version->update(); + } else { + # no bugs, safe to delete + $version->remove_from_db(); + } +} + +sub check_setup { + my ($self, $full) = @_; + $self->{setup_error} = ''; + + if (!$self->_target) { + $self->{setup_error} = "TellUsMore: Error: Target product '" . VERSION_TARGET_PRODUCT . "' does not exist.\n"; + return 0; + } + return 1 unless $full; + + foreach my $name (VERSION_SOURCE_PRODUCTS) { + my $product = Bugzilla::Product->new({ name => $name }); + if (!$product) { + $self->{setup_error} .= "TellUsMore: Warning: Source product '$name' does not exist.\n"; + next; + } + my $component = Bugzilla::Component->new({ product => $self->_target, name => $name }); + if (!$component) { + $self->{setup_error} .= "TellUsMore: Warning: Target component '$name' does not exist.\n"; + } + } + return $self->{setup_error} ? 0 : 1; +} + +sub setup_error { + my ($self) = @_; + return $self->{setup_error}; +} + +sub refresh { + my ($self) = @_; + foreach my $product ($self->_sources) { + foreach my $version (@{$product->versions}) { + if (!$self->_get($version)) { + $self->created($version); + } + } + } +} + +sub _should_process { + my ($self, $version) = @_; + return 0 unless $self->check_setup(); + foreach my $product ($self->_sources) { + return 1 if $version->product->name eq $product->name; + } + return 0; +} + +sub _get { + my ($self, $query) = @_; + my $name = ref($query) ? $query->name : $query; + my @versions = grep { $_->name eq $name } @{$self->_target->versions}; + return scalar @versions ? $versions[0] : undef; +} + +sub _sources { + my ($self) = @_; + if (!$self->{sources} || scalar(@{$self->{sources}}) != scalar VERSION_SOURCE_PRODUCTS) { + my @sources; + foreach my $name (VERSION_SOURCE_PRODUCTS) { + my $product = Bugzilla::Product->new({ name => $name }); + push @sources, $product if $product; + } + $self->{sources} = \@sources; + } + return @{$self->{sources}}; +} + +sub _target { + my ($self) = @_; + $self->{target} ||= Bugzilla::Product->new({ name => VERSION_TARGET_PRODUCT }); + return $self->{target}; +} + +sub _version_eq { + my ($version_a, $version_b) = @_; + return lc($version_a->name) eq lc($version_b->name); +} + +sub _create_version { + my ($self, $name) = @_; + Bugzilla::Version->create({ product => $self->_target, value => $name }); + # remove bugzilla's cached list of versions + delete $self->_target->{versions}; +} + +1; diff --git a/extensions/TellUsMore/lib/WebService.pm b/extensions/TellUsMore/lib/WebService.pm new file mode 100644 index 000000000..3ace06ef3 --- /dev/null +++ b/extensions/TellUsMore/lib/WebService.pm @@ -0,0 +1,259 @@ +# 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::Extension::TellUsMore::WebService; + +use strict; +use warnings; + +use base qw(Bugzilla::WebService Bugzilla::Extension); + +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Mailer; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::UserAgent; +use Bugzilla::Util; +use Bugzilla::Version; + +use Bugzilla::Extension::TellUsMore::Constants; + +use Data::Dumper; +use Email::MIME; +use MIME::Base64; + +sub submit { + my ($self, $params) = @_; + my $dbh = Bugzilla->dbh; + + # validation + + my $user = Bugzilla->login(LOGIN_REQUIRED); + if ($user->email ne TELL_US_MORE_LOGIN) { + ThrowUserError('tum_auth_failure'); + } + + if (Bugzilla->params->{disable_bug_updates}) { + ThrowUserError('tum_updates_disabled'); + } + + $self->_validate_params($params); + $self->_set_missing_params($params); + + my $creator = $self->_get_user($params->{creator}); + if ($creator && $creator->disabledtext ne '') { + ThrowUserError('tum_account_disabled', { user => $creator }); + } + + $self->_validate_rate($params); + + # create transient entry and email + + $dbh->bz_start_transaction(); + my $token = Bugzilla::Token::GenerateUniqueToken('tell_us_more', 'token'); + my $id = $self->_insert($params, $token); + my $email = $self->_generate_email($params, $token, $creator); + $dbh->bz_commit_transaction(); + + # send email + + MessageToMTA($email); + + # done, return the id from the tell_us_more table + + return $id; +} + +sub _validate_params { + my ($self, $params) = @_; + + $self->_validate_mandatory($params, 'Submission', MANDATORY_BUG_FIELDS); + $self->_remove_invalid_fields($params, MANDATORY_BUG_FIELDS, OPTIONAL_BUG_FIELDS); + + if (!validate_email_syntax($params->{creator})) { + ThrowUserError('illegal_email_address', { addr => $params->{creator} }); + } + + if ($params->{attachments}) { + if (scalar @{$params->{attachments}} > MAX_ATTACHMENT_COUNT) { + ThrowUserError('tum_too_many_attachments', { max => MAX_ATTACHMENT_COUNT }); + } + my $i = 0; + foreach my $attachment (@{$params->{attachments}}) { + $i++; + $self->_validate_mandatory($attachment, "Attachment $i", MANDATORY_ATTACH_FIELDS); + $self->_remove_invalid_fields($attachment, MANDATORY_ATTACH_FIELDS, OPTIONAL_ATTACH_FIELDS); + if (length(decode_base64($attachment->{content})) > MAX_ATTACHMENT_SIZE * 1024) { + ThrowUserError('tum_attachment_too_large', { filename => $attachment->{filename}, max => MAX_ATTACHMENT_SIZE }); + } + } + } + + # products are mapped to components of the target-product + + Bugzilla::Component->new({ name => $params->{product}, product => $self->_target_product }) + || ThrowUserError('invalid_product_name', { product => $params->{product} }); +} + +sub _set_missing_params { + my ($self, $params) = @_; + + # set the product and component correctly + + $params->{component} = $params->{product}; + $params->{product} = TARGET_PRODUCT; + + # priority, bug_severity + + $params->{priority} = Bugzilla->params->{defaultpriority}; + $params->{bug_severity} = Bugzilla->params->{defaultseverity}; + + # map invalid versions to 'unspecified' + + if (!$params->{version}) { + $params->{version} = DEFAULT_VERSION; + } else { + Bugzilla::Version->new({ product => $self->_target_product, name => $params->{version} }) + || ($params->{version} = DEFAULT_VERSION); + } + + # set url + + $params->{bug_file_loc} = $params->{url}; + + # detect the opsys and platform from user_agent + + $ENV{HTTP_USER_AGENT} = $params->{user_agent}; + $params->{rep_platform} = detect_platform(); + $params->{op_sys} = detect_op_sys(); + + # set group based on restricted + + $params->{group} = $params->{restricted} ? SECURITY_GROUP : ''; + delete $params->{restricted}; +} + +sub _get_user { + my ($self, $email) = @_; + + return Bugzilla::User->new({ name => $email }); +} + +sub _insert { + my ($self, $params, $token) = @_; + my $dbh = Bugzilla->dbh; + + local $Data::Dumper::Purity = 1; + local $Data::Dumper::Sortkeys = 1; + my $content = Dumper($params); + trick_taint($content); + + my $sth = $dbh->prepare(' + INSERT INTO tell_us_more(token, mail, creation_ts, content) + VALUES(?, ?, ?, ?) + '); + $sth->bind_param(1, $token); + $sth->bind_param(2, $params->{creator}); + $sth->bind_param(3, $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)')); + $sth->bind_param(4, $content, $dbh->BLOB_TYPE); + $sth->execute(); + + return $dbh->bz_last_key('tell_us_more', 'id'); +} + +sub _generate_email { + my ($self, $params, $token, $user) = @_; + + # create email parts + + my $template = Bugzilla->template_inner; + my ($message_header, $message_text, $message_html); + my $vars = { + token_url => correct_urlbase() . 'page.cgi?id=tellusmore.html&token=' . url_quote($token), + recipient_email => $params->{creator}, + recipient_name => ($user ? $user->name : $params->{creator_name}), + }; + + my $prefix = $user ? 'existing' : 'new'; + $template->process("email/$prefix-account.header.tmpl", $vars, \$message_header) + || ThrowCodeError('template_error', { template_error_msg => $template->error() }); + $template->process("email/$prefix-account.txt.tmpl", $vars, \$message_text) + || ThrowCodeError('template_error', { template_error_msg => $template->error() }); + $template->process("email/$prefix-account.html.tmpl", $vars, \$message_html) + || ThrowCodeError('template_error', { template_error_msg => $template->error() }); + + # create email object + + my @parts = ( + Email::MIME->create( + attributes => { content_type => "text/plain" }, + body => $message_text, + ), + Email::MIME->create( + attributes => { content_type => "text/html" }, + body => $message_html, + ), + ); + my $email = new Email::MIME("$message_header\n"); + $email->content_type_set('multipart/alternative'); + $email->parts_set(\@parts); + + return $email; +} + +sub _validate_mandatory { + my ($self, $params, $name, @fields) = @_; + + my @missing_fields; + foreach my $field (@fields) { + if (!exists $params->{$field} || $params->{$field} eq '') { + push @missing_fields, $field; + } + } + + if (scalar @missing_fields) { + ThrowUserError('tum_missing_fields', { name => $name, missing => \@missing_fields }); + } +} + +sub _remove_invalid_fields { + my ($self, $params, @valid_fields) = @_; + + foreach my $field (keys %$params) { + if (!grep { $_ eq $field } @valid_fields) { + delete $params->{$field}; + } + } +} + +sub _validate_rate { + my ($self, $params) = @_; + my $dbh = Bugzilla->dbh; + + my ($report_count) = $dbh->selectrow_array(' + SELECT COUNT(*) + FROM tell_us_more + WHERE mail = ? + AND creation_ts >= NOW() - ' . $dbh->sql_interval(1, 'MINUTE') + , undef, $params->{creator} + ); + if ($report_count + 1 > MAX_REPORTS_PER_MINUTE) { + ThrowUserError('tum_rate_exceeded', { max => MAX_REPORTS_PER_MINUTE }); + } +} + +sub _target_product { + my ($self) = @_; + + my $product = Bugzilla::Product->new({ name => TARGET_PRODUCT }) + || ThrowUserError('invalid_product_name', { product => TARGET_PRODUCT }); + return $product; +} + +1; |