diff options
36 files changed, 4493 insertions, 0 deletions
diff --git a/extensions/Push/Config.pm b/extensions/Push/Config.pm new file mode 100644 index 000000000..45cae9183 --- /dev/null +++ b/extensions/Push/Config.pm @@ -0,0 +1,61 @@ +# 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::Push; + +use strict; + +use constant NAME => 'Push'; + +use constant REQUIRED_MODULES => [ + { + package => 'Daemon-Generic', + module => 'Daemon::Generic', + version => '0' + }, + { + package => 'JSON-XS', + module => 'JSON::XS', + version => '2.0' + }, + { + package => 'Crypt-CBC', + module => 'Crypt::CBC', + version => '0' + }, + { + package => 'Crypt-DES', + module => 'Crypt::DES', + version => '0' + }, + { + package => 'Crypt-DES_EDE3', + module => 'Crypt::DES_EDE3', + version => '0' + }, +]; + +use constant OPTIONAL_MODULES => [ + # connectors need the ability to extend this + { + package => 'Net--RabbitMQ', + module => 'Net::RabbitMQ', + version => '0' + }, + { + package => 'Net-SFTP', + module => 'Net::SFTP', + version => '0' + }, + { + package => 'XML-Simple', + module => 'XML::Simple', + version => '0' + }, +]; + +__PACKAGE__->NAME; diff --git a/extensions/Push/Extension.pm b/extensions/Push/Extension.pm new file mode 100644 index 000000000..f48a60210 --- /dev/null +++ b/extensions/Push/Extension.pm @@ -0,0 +1,637 @@ +# 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::Push; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Comment; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Admin; +use Bugzilla::Extension::Push::Connectors; +use Bugzilla::Extension::Push::Logger; +use Bugzilla::Extension::Push::Message; +use Bugzilla::Extension::Push::Push; +use Bugzilla::Extension::Push::Serialise; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Install::Filesystem; + +use Encode; +use Scalar::Util 'blessed'; +use Storable 'dclone'; + +our $VERSION = '1'; + +$Carp::CarpInternal{'CGI::Carp'} = 1; + +# +# monkey patch for convience +# + +BEGIN { + *Bugzilla::push_ext = \&_get_instance; +} + +my $_instance; +sub _get_instance { + if (!$_instance) { + $_instance = Bugzilla::Extension::Push::Push->new(); + $_instance->logger(Bugzilla::Extension::Push::Logger->new()); + $_instance->connectors(Bugzilla::Extension::Push::Connectors->new()); + } + return $_instance; +} + +# +# enabled +# + +sub _enabled { + my ($self) = @_; + if (!exists $self->{'enabled'}) { + my $push = Bugzilla->push_ext; + $self->{'enabled'} = $push->config->{enabled} eq 'Enabled'; + if ($self->{'enabled'}) { + # if no connectors are enabled, no need to push anything + $self->{'enabled'} = 0; + foreach my $connector (Bugzilla->push_ext->connectors->list) { + if ($connector->enabled) { + $self->{'enabled'} = 1; + last; + } + } + } + } + return $self->{'enabled'}; +} + +# +# deal with creation and updated events +# + +sub _object_created { + my ($self, $args) = @_; + + my $object = _get_object_from_args($args); + return unless $object; + return unless _should_push($object); + + $self->_push_object('create', $object, change_set_id(), { timestamp => $args->{'timestamp'} }); +} + +sub _object_modified { + my ($self, $args) = @_; + + my $object = _get_object_from_args($args); + return unless $object; + return unless _should_push($object); + + my $changes = $args->{'changes'} || {}; + return unless scalar keys %$changes; + + my $change_set = change_set_id(); + + # detect when a bug changes from public to private (or back), so connectors + # can remove now-private bugs if required. + if ($object->isa('Bugzilla::Bug')) { + # we can't use user->can_see_bug(old_bug) as that works on IDs, and the + # bug has already been updated, so for now assume that a bug without + # groups is public. + my $old_bug = $args->{'old_bug'}; + my $is_public = is_public($object); + my $was_public = $old_bug ? !@{$old_bug->groups_in} : $is_public; + + if (!$is_public && $was_public) { + # bug is changing from public to private + # push a fake update with the just is_private change + my $private_changes = { + timestamp => $args->{'timestamp'}, + changes => [ + { + field => 'is_private', + removed => '0', + added => '1', + }, + ], + }; + # note we're sending the old bug object so we don't leak any + # security sensitive information. + $self->_push_object('modify', $old_bug, $change_set, $private_changes); + } elsif ($is_public && !$was_public) { + # bug is changing from private to public + # push a fake update with the just is_private change + my $private_changes = { + timestamp => $args->{'timestamp'}, + changes => [ + { + field => 'is_private', + removed => '1', + added => '0', + }, + ], + }; + # it's ok to send the new bug state here + $self->_push_object('modify', $object, $change_set, $private_changes); + } + } + + # make flagtypes changes easier to process + if (exists $changes->{'flagtypes.name'}) { + _split_flagtypes($changes); + } + + # TODO split group changes? + + # restructure the changes hash + my $changes_data = { + timestamp => $args->{'timestamp'}, + changes => [], + }; + foreach my $field_name (sort keys %$changes) { + push @{$changes_data->{'changes'}}, { + field => $field_name, + removed => $changes->{$field_name}[0], + added => $changes->{$field_name}[1], + }; + } + + $self->_push_object('modify', $object, $change_set, $changes_data); +} + +sub _get_object_from_args { + my ($args) = @_; + return get_first_value($args, qw(object bug flag group)); +} + +sub _should_push { + my ($object_or_class) = @_; + my $class = blessed($object_or_class) || $object_or_class; + return grep { $_ eq $class } qw(Bugzilla::Bug Bugzilla::Attachment Bugzilla::Comment); +} + +# changes to bug flags are presented in a single field 'flagtypes.name' split +# into individual fields +sub _split_flagtypes { + my ($changes) = @_; + + my @removed = _split_flagtype($changes->{'flagtypes.name'}->[0]); + my @added = _split_flagtype($changes->{'flagtypes.name'}->[1]); + delete $changes->{'flagtypes.name'}; + + foreach my $ra (@removed, @added) { + $changes->{$ra->[0]} = ['', '']; + } + foreach my $ra (@removed) { + my ($name, $value) = @$ra; + $changes->{$name}->[0] = $value; + } + foreach my $ra (@added) { + my ($name, $value) = @$ra; + $changes->{$name}->[1] = $value; + } +} + +sub _split_flagtype { + my ($value) = @_; + my @result; + foreach my $change (split(/, /, $value)) { + my $requestee = ''; + if ($change =~ s/\(([^\)]+)\)$//) { + $requestee = $1; + } + my ($name, $value) = $change =~ /^(.+)(.)$/; + $value .= " ($requestee)" if $requestee; + push @result, [ "flag.$name", $value ]; + } + return @result; +} + +# changes to attachment flags come in via flag_end_of_update which has a +# completely different structure for reporting changes than +# object_end_of_update. this morphs flag to object updates. +sub _morph_flag_updates { + my ($args) = @_; + + my @removed = _morph_flag_update($args->{'old_flags'}); + my @added = _morph_flag_update($args->{'new_flags'}); + delete $args->{'old_flags'}; + delete $args->{'new_flags'}; + + my $changes = {}; + foreach my $ra (@removed, @added) { + $changes->{$ra->[0]} = ['', '']; + } + foreach my $ra (@removed) { + my ($name, $value) = @$ra; + $changes->{$name}->[0] = $value; + } + foreach my $ra (@added) { + my ($name, $value) = @$ra; + $changes->{$name}->[1] = $value; + } + + foreach my $flag (keys %$changes) { + if ($changes->{$flag}->[0] eq $changes->{$flag}->[1]) { + delete $changes->{$flag}; + } + } + + $args->{'changes'} = $changes; +} + +sub _morph_flag_update { + my ($values) = @_; + my @result; + foreach my $change (@$values) { + $change =~ s/^[^:]+://; + my $requestee = ''; + if ($change =~ s/\(([^\)]+)\)$//) { + $requestee = $1; + } + my ($name, $value) = $change =~ /^(.+)(.)$/; + $value .= " ($requestee)" if $requestee; + push @result, [ "flag.$name", $value ]; + } + return @result; +} + +# +# serialise and insert into the table +# + +sub _push_object { + my ($self, $message_type, $object, $change_set, $changes) = @_; + my $rh; + + # serialise the object + my ($rh_object, $name) = Bugzilla::Extension::Push::Serialise->instance->object_to_hash($object); + + if (!$rh_object) { + warn "empty hash from serialiser ($message_type $object)\n"; + return; + } + $rh->{$name} = $rh_object; + + # add in the events hash + my $rh_event = Bugzilla::Extension::Push::Serialise->instance->changes_to_event($changes); + return unless $rh_event; + $rh_event->{'action'} = $message_type; + $rh_event->{'target'} = $name; + $rh_event->{'change_set'} = $change_set; + $rh_event->{'routing_key'} = "$name.$message_type"; + if (exists $rh_event->{'changes'}) { + $rh_event->{'routing_key'} .= ':' . join(',', map { $_->{'field'} } @{$rh_event->{'changes'}}); + } + $rh->{'event'} = $rh_event; + + # create message object + my $message = Bugzilla::Extension::Push::Message->new_transient({ + payload => to_json($rh), + change_set => $change_set, + routing_key => $rh_event->{'routing_key'}, + }); + + # don't hit the database unless there are interested connectors + my $should_push = 0; + foreach my $connector (Bugzilla->push_ext->connectors->list) { + next unless $connector->enabled; + next unless $connector->should_send($message); + $should_push = 1; + last; + } + return unless $should_push; + + # insert into push table + $message->create_from_transient(); +} + +# +# update/create hooks +# + +sub object_end_of_create { + my ($self, $args) = @_; + return unless $self->_enabled; + + # it's better to process objects from a non-generic end_of_create where + # possible; don't process them here to avoid duplicate messages + my $object = _get_object_from_args($args); + return if !$object || + $object->isa('Bugzilla::Bug'); + + $self->_object_created($args); +} + +sub object_end_of_update { + my ($self, $args) = @_; + return unless $self->_enabled; + + # it's better to process objects from a non-generic end_of_update where + # possible; don't process them here to avoid duplicate messages + my $object = _get_object_from_args($args); + return if !$object || + $object->isa('Bugzilla::Bug') || + $object->isa('Bugzilla::Flag'); + + $self->_object_modified($args); +} + +# process bugs once they are fully formed +# object_end_of_update is triggered while a bug is being created +sub bug_end_of_create { + my ($self, $args) = @_; + return unless $self->_enabled; + $self->_object_created($args); +} + +sub bug_end_of_update { + my ($self, $args) = @_; + return unless $self->_enabled; + $self->_object_modified($args); +} + +sub flag_end_of_update { + my ($self, $args) = @_; + return unless $self->_enabled; + _morph_flag_updates($args); + $self->_object_modified($args); +} + +# comments in bugzilla 4.0 doesn't aren't included in the bug_end_of_* hooks, +# this code uses custom hooks to trigger +sub bug_comment_create { + my ($self, $args) = @_; + return unless $self->_enabled; + + return unless _should_push('Bugzilla::Comment'); + my $bug = $args->{'bug'} or return; + my $timestamp = $args->{'timestamp'} or return; + + my $comments = Bugzilla::Comment->match({ bug_id => $bug->id, bug_when => $timestamp }); + + foreach my $comment (@$comments) { + if ($comment->body ne '') { + $self->_push_object('create', $comment, change_set_id(), { timestamp => $timestamp }); + } + } +} + +sub bug_comment_update { + my ($self, $args) = @_; + return unless $self->_enabled; + + return unless _should_push('Bugzilla::Comment'); + my $bug = $args->{'bug'} or return; + my $timestamp = $args->{'timestamp'} or return; + + my $comment_id = $args->{'comment_id'}; + if ($comment_id) { + # XXX this should set changes. only is_private changes will trigger this event + my $comment = Bugzilla::Comment->new($comment_id); + $self->_push_object('update', $comment, change_set_id(), { timestamp => $timestamp }); + + } else { + # when a bug is created, an update is also triggered; we don't want to sent + # update messages for the initial comment, or for empty comments + my $comments = Bugzilla::Comment->match({ bug_id => $bug->id, bug_when => $timestamp }); + foreach my $comment (@$comments) { + if ($comment->body ne '' && $comment->count) { + $self->_push_object('create', $comment, change_set_id(), { timestamp => $timestamp }); + } + } + } +} + +# +# admin hooks +# + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{'page_id'}; + my $vars = $args->{'vars'}; + + if ($page eq 'push_config.html') { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_config($vars); + + } elsif ($page eq 'push_queues.html' + || $page eq 'push_queues_view.html' + ) { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_queues($vars, $page); + + } elsif ($page eq 'push_log.html') { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_log($vars); + } +} + +# +# installation/config hooks +# + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'push'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + push_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + payload => { + TYPE => 'LONGTEXT', + NOTNULL => 1, + }, + change_set => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + routing_key => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + ], + }; + $args->{'schema'}->{'push_backlog'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + message_id => { + TYPE => 'INT3', + NOTNULL => 1, + }, + push_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + payload => { + TYPE => 'LONGTEXT', + NOTNULL => 1, + }, + change_set => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + routing_key => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + attempt_ts => { + TYPE => 'DATETIME', + }, + attempts => { + TYPE => 'INT2', + NOTNULL => 1, + }, + last_error => { + TYPE => 'MEDIUMTEXT', + }, + ], + INDEXES => [ + push_backlog_idx => { + FIELDS => ['message_id', 'connector'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'push_backoff'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + next_attempt_ts => { + TYPE => 'DATETIME', + }, + attempts => { + TYPE => 'INT2', + NOTNULL => 1, + }, + ], + INDEXES => [ + push_backoff_idx => { + FIELDS => ['connector'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'push_options'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + option_name => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + option_value => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + }, + ], + INDEXES => [ + push_options_idx => { + FIELDS => ['connector', 'option_name'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'push_log'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + message_id => { + TYPE => 'INT3', + NOTNULL => 1, + }, + change_set => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + routing_key => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + push_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + processed_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + result => { + TYPE => 'INT1', + NOTNULL => 1, + }, + data => { + TYPE => 'MEDIUMTEXT', + }, + ], + }; +} + +sub install_filesystem { + my ($self, $args) = @_; + my $files = $args->{'files'}; + + my $extensionsdir = bz_locations()->{'extensionsdir'}; + my $scriptname = $extensionsdir . "/Push/bin/bugzilla-pushd.pl"; + + $files->{$scriptname} = { + perms => Bugzilla::Install::Filesystem::WS_EXECUTE + }; +} + +__PACKAGE__->NAME; diff --git a/extensions/Push/bin/bugzilla-pushd.pl b/extensions/Push/bin/bugzilla-pushd.pl new file mode 100755 index 000000000..f048df157 --- /dev/null +++ b/extensions/Push/bin/bugzilla-pushd.pl @@ -0,0 +1,54 @@ +#!/usr/bin/perl + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +use strict; +use warnings; + +use FindBin '$RealBin'; +use lib "$RealBin/../../.."; +use lib "$RealBin/../../../lib"; +use lib "$RealBin/../lib"; + +BEGIN { + use Bugzilla; + Bugzilla->extensions; +} + +use Bugzilla::Extension::Push::Daemon; +Bugzilla::Extension::Push::Daemon->start(); + +=head1 NAME + +bugzilla-push.pl - Pushes changes queued by the Push extension to connectors. + +=head1 SYNOPSIS + + bugzilla-push.pl [OPTIONS] COMMAND + + OPTIONS: + -f Run in the foreground (don't detach) + -d Output a lot of debugging information + -p file Specify the file where bugzilla-push.pl should store its current + process id. Defaults to F<data/bugzilla-push.pl.pid>. + -n name What should this process call itself in the system log? + Defaults to the full path you used to invoke the script. + + COMMANDS: + start Starts a new bugzilla-push daemon if there isn't one running already + stop Stops a running bugzilla-push daemon + restart Stops a running bugzilla-push if one is running, and then + starts a new one. + check Report the current status of the daemon. + install On some *nix systems, this automatically installs and + configures bugzilla-push.pl as a system service so that it will + start every time the machine boots. + uninstall Removes the system service for bugzilla-push.pl. + help Display this usage info + + diff --git a/extensions/Push/lib/Admin.pm b/extensions/Push/lib/Admin.pm new file mode 100644 index 000000000..d7df25c09 --- /dev/null +++ b/extensions/Push/lib/Admin.pm @@ -0,0 +1,121 @@ +# 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::Push::Admin; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Util qw(trim detaint_natural); + +use base qw(Exporter); +our @EXPORT = qw( + admin_config + admin_queues + admin_log +); + +sub admin_config { + my ($vars) = @_; + my $push = Bugzilla->push_ext; + my $input = Bugzilla->input_params; + + if ($input->{save}) { + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + _update_config_from_form('global', $push->config); + foreach my $connector ($push->connectors->list) { + _update_config_from_form($connector->name, $connector->config); + } + $push->set_config_last_modified(); + $dbh->bz_commit_transaction(); + $vars->{message} = 'push_config_updated'; + } + + $vars->{push} = $push; + $vars->{connectors} = $push->connectors; +} + +sub _update_config_from_form { + my ($name, $config) = @_; + my $input = Bugzilla->input_params; + + # read values from form + my $values = {}; + foreach my $option ($config->options) { + my $option_name = $option->{name}; + $values->{$option_name} = trim($input->{$name . ".$option_name"}); + } + + # validate + if ($values->{enabled} eq 'Enabled') { + eval { + $config->validate($values); + }; + if ($@) { + ThrowUserError('push_error', { error_message => clean_error($@) }); + } + } + + # update + foreach my $option ($config->options) { + my $option_name = $option->{name}; + $config->{$option_name} = $values->{$option_name}; + } + $config->update(); +} + +sub admin_queues { + my ($vars, $page) = @_; + my $push = Bugzilla->push_ext; + my $input = Bugzilla->input_params; + + if ($page eq 'push_queues.html') { + $vars->{push} = $push; + + } elsif ($page eq 'push_queues_view.html') { + my $queue; + if ($input->{connector}) { + my $connector = $push->connectors->by_name($input->{connector}) + || ThrowUserError('push_error', { error_message => 'Invalid connector' }); + $queue = $connector->backlog; + } else { + $queue = $push->queue; + } + $vars->{queue} = $queue; + + my $id = $input->{message} || 0; + detaint_natural($id) + || ThrowUserError('push_error', { error_message => 'Invalid message ID' }); + my $message = $queue->by_id($id) + || ThrowUserError('push_error', { error_message => 'Invalid message ID' }); + + if ($input->{delete}) { + $message->remove_from_db(); + $vars->{message} = 'push_message_deleted'; + + } else { + $vars->{message_obj} = $message; + eval { + $vars->{json} = to_json($message->payload_decoded, 1); + }; + } + } +} + +sub admin_log { + my ($vars) = @_; + my $push = Bugzilla->push_ext; + my $input = Bugzilla->input_params; + + $vars->{push} = $push; +} + +1; diff --git a/extensions/Push/lib/BacklogMessage.pm b/extensions/Push/lib/BacklogMessage.pm new file mode 100644 index 000000000..f9496fa24 --- /dev/null +++ b/extensions/Push/lib/BacklogMessage.pm @@ -0,0 +1,145 @@ +# 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::Push::BacklogMessage; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Util; +use Encode; + +# +# initialisation +# + +use constant DB_TABLE => 'push_backlog'; +use constant DB_COLUMNS => qw( + id + message_id + push_ts + payload + change_set + routing_key + connector + attempt_ts + attempts + last_error +); +use constant UPDATE_COLUMNS => qw( + attempt_ts + attempts + last_error +); +use constant LIST_ORDER => 'push_ts'; +use constant VALIDATORS => { + payload => \&_check_payload, + change_set => \&_check_change_set, + routing_key => \&_check_routing_key, + connector => \&_check_connector, + attempts => \&_check_attempts, +}; + +# +# constructors +# + +sub create_from_message { + my ($class, $message, $connector) = @_; + my $self = $class->create({ + message_id => $message->id, + push_ts => $message->push_ts, + payload => $message->payload, + change_set => $message->change_set, + routing_key => $message->routing_key, + connector => $connector->name, + attempt_ts => undef, + attempts => 0, + last_error => undef, + }); + return $self; +} + +# +# accessors +# + +sub message_id { return $_[0]->{'message_id'} } +sub push_ts { return $_[0]->{'push_ts'}; } +sub payload { return $_[0]->{'payload'}; } +sub change_set { return $_[0]->{'change_set'}; } +sub routing_key { return $_[0]->{'routing_key'}; } +sub connector { return $_[0]->{'connector'}; } +sub attempt_ts { return $_[0]->{'attempt_ts'}; } +sub attempts { return $_[0]->{'attempts'}; } +sub last_error { return $_[0]->{'last_error'}; } + +sub payload_decoded { + my ($self) = @_; + return from_json($self->{'payload'}); +} + +sub attempt_time { + my ($self) = @_; + if (!exists $self->{'attempt_time'}) { + $self->{'attempt_time'} = datetime_from($self->attempt_ts)->epoch; + } + return $self->{'attempt_time'}; +} + +# +# mutators +# + +sub inc_attempts { + my ($self, $error) = @_; + $self->{attempt_ts} = Bugzilla->dbh->selectrow_array('SELECT NOW()'); + $self->{attempts} = $self->{attempts} + 1; + $self->{last_error} = $error; + $self->update; +} + +# +# validators +# + +sub _check_payload { + my ($invocant, $value) = @_; + length($value) || ThrowCodeError('push_invalid_payload'); + return $value; +} + +sub _check_change_set { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_change_set'); + return $value; +} + +sub _check_routing_key { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_routing_key'); + return $value; +} + +sub _check_connector { + my ($invocant, $value) = @_; + Bugzilla->push_ext->connectors->exists($value) || ThrowCodeError('push_invalid_connector'); + return $value; +} + +sub _check_attempts { + my ($invocant, $value) = @_; + return $value || 0; +} + +1; + diff --git a/extensions/Push/lib/BacklogQueue.pm b/extensions/Push/lib/BacklogQueue.pm new file mode 100644 index 000000000..79b9b72ee --- /dev/null +++ b/extensions/Push/lib/BacklogQueue.pm @@ -0,0 +1,127 @@ +# 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::Push::BacklogQueue; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::BacklogMessage; + +sub new { + my ($class, $connector) = @_; + my $self = {}; + bless($self, $class); + $self->{connector} = $connector; + return $self; +} + +sub count { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return $dbh->selectrow_array(" + SELECT COUNT(*) + FROM push_backlog + WHERE connector = ?", + undef, + $self->{connector}); +} + +sub oldest { + my ($self) = @_; + my @messages = $self->list( + limit => 1, + filter => 'AND ((next_attempt_ts IS NULL) OR (next_attempt_ts <= NOW()))', + ); + return scalar(@messages) ? $messages[0] : undef; +} + +sub by_id { + my ($self, $id) = @_; + my @messages = $self->list( + limit => 1, + filter => "AND (log.id = $id)", + ); + return scalar(@messages) ? $messages[0] : undef; +} + +sub list { + my ($self, %args) = @_; + $args{limit} ||= 10; + $args{filter} ||= ''; + my @result; + my $dbh = Bugzilla->dbh; + + my $filter_sql = $args{filter} || ''; + my $sth = $dbh->prepare(" + SELECT log.id, message_id, push_ts, payload, change_set, routing_key, attempt_ts, log.attempts + FROM push_backlog log + LEFT JOIN push_backoff off ON off.connector = log.connector + WHERE log.connector = ? ". + $args{filter} . " + ORDER BY push_ts " . + $dbh->sql_limit($args{limit}) + ); + $sth->execute($self->{connector}); + while (my $row = $sth->fetchrow_hashref()) { + push @result, Bugzilla::Extension::Push::BacklogMessage->new({ + id => $row->{id}, + message_id => $row->{message_id}, + push_ts => $row->{push_ts}, + payload => $row->{payload}, + change_set => $row->{change_set}, + routing_key => $row->{routing_key}, + connector => $self->{connector}, + attempt_ts => $row->{attempt_ts}, + attempts => $row->{attempts}, + }); + } + return @result; +} + +# +# backoff +# + +sub backoff { + my ($self) = @_; + if (!$self->{backoff}) { + my $ra = Bugzilla::Extension::Push::Backoff->match({ + connector => $self->{connector} + }); + if (@$ra) { + $self->{backoff} = $ra->[0]; + } else { + $self->{backoff} = Bugzilla::Extension::Push::Backoff->create({ + connector => $self->{connector} + }); + } + } + return $self->{backoff}; +} + +sub reset_backoff { + my ($self) = @_; + my $backoff = $self->backoff; + $backoff->reset(); + $backoff->update(); +} + +sub inc_backoff { + my ($self) = @_; + my $backoff = $self->backoff; + $backoff->inc(); + $backoff->update(); +} + +sub connector { + my ($self) = @_; + return $self->{connector}; +} + +1; diff --git a/extensions/Push/lib/Backoff.pm b/extensions/Push/lib/Backoff.pm new file mode 100644 index 000000000..bc302a2a9 --- /dev/null +++ b/extensions/Push/lib/Backoff.pm @@ -0,0 +1,105 @@ +# 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::Push::Backoff; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use Bugzilla; +use Bugzilla::Util; + +# +# initialisation +# + +use constant DB_TABLE => 'push_backoff'; +use constant DB_COLUMNS => qw( + id + connector + next_attempt_ts + attempts +); +use constant UPDATE_COLUMNS => qw( + next_attempt_ts + attempts +); +use constant VALIDATORS => { + connector => \&_check_connector, + next_attempt_ts => \&_check_next_attempt_ts, + attempts => \&_check_attempts, +}; +use constant LIST_ORDER => 'next_attempt_ts'; + +# +# accessors +# + +sub connector { return $_[0]->{'connector'}; } +sub next_attempt_ts { return $_[0]->{'next_attempt_ts'}; } +sub attempts { return $_[0]->{'attempts'}; } + +sub next_attempt_time { + my ($self) = @_; + if (!exists $self->{'next_attempt_time'}) { + $self->{'next_attempt_time'} = datetime_from($self->next_attempt_ts)->epoch; + } + return $self->{'next_attempt_time'}; +} + +# +# mutators +# + +sub reset { + my ($self) = @_; + $self->{next_attempt_ts} = Bugzilla->dbh->selectrow_array('SELECT NOW()'); + $self->{attempts} = 0; + Bugzilla->push_ext->logger->debug( + sprintf("resetting backoff for %s", $self->connector) + ); +} + +sub inc { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + my $attempts = $self->attempts + 1; + my $seconds = $attempts <= 4 ? 5 ** $attempts : 15 * 60; + my ($date) = $dbh->selectrow_array("SELECT NOW() + " . $dbh->sql_interval($seconds, 'SECOND')); + + $self->{next_attempt_ts} = $date; + $self->{attempts} = $attempts; + Bugzilla->push_ext->logger->debug( + sprintf("setting next attempt for %s to %s (attempt %s)", $self->connector, $date, $attempts) + ); +} + +# +# validators +# + +sub _check_connector { + my ($invocant, $value) = @_; + Bugzilla->push_ext->connectors->exists($value) || ThrowCodeError('push_invalid_connector'); + return $value; +} + +sub _check_next_attempt_ts { + my ($invocant, $value) = @_; + return $value || Bugzilla->dbh->selectrow_array('SELECT NOW()'); +} + +sub _check_attempts { + my ($invocant, $value) = @_; + return $value || 0; +} + +1; + diff --git a/extensions/Push/lib/Config.pm b/extensions/Push/lib/Config.pm new file mode 100644 index 000000000..31fa6af36 --- /dev/null +++ b/extensions/Push/lib/Config.pm @@ -0,0 +1,215 @@ +# 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::Push::Config; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Option; +use Crypt::CBC; + +sub new { + my ($class, $name, @options) = @_; + my $self = { + _name => $name + }; + bless($self, $class); + + $self->{_options} = [@options]; + unshift @{$self->{_options}}, { + name => 'enabled', + label => 'Status', + help => '', + type => 'select', + values => [ 'Enabled', 'Disabled' ], + default => 'Disabled', + }; + + return $self; +} + +sub options { + my ($self) = @_; + return @{$self->{_options}}; +} + +sub option { + my ($self, $name) = @_; + foreach my $option ($self->options) { + return $option if $option->{name} eq $name; + } + return undef; +} + +sub load { + my ($self) = @_; + my $config = {}; + my $logger = Bugzilla->push_ext->logger; + + # prime $config with defaults + foreach my $rh ($self->options) { + $config->{$rh->{name}} = $rh->{default}; + } + + # override defaults with values from database + my $options = Bugzilla::Extension::Push::Option->match({ + connector => $self->{_name}, + }); + foreach my $option (@$options) { + my $option_config = $self->option($option->name) + || next; + if ($option_config->{type} eq 'password') { + $config->{$option->name} = $self->_decrypt($option->value); + } else { + $config->{$option->name} = $option->value; + } + } + + # validate when running from the daemon + if (Bugzilla->push_ext->is_daemon) { + $self->_validate_config($config); + } + + # done, update self + foreach my $name (keys %$config) { + my $value = $self->option($name)->{type} eq 'password' ? '********' : $config->{$name}; + $logger->debug(sprintf("%s: set %s=%s\n", $self->{_name}, $name, $value)); + $self->{$name} = $config->{$name}; + } +} + +sub validate { + my ($self, $config) = @_; + $self->_validate_mandatory($config); + $self->_validate_config($config); +} + +sub update { + my ($self) = @_; + + my @valid_options = map { $_->{name} } $self->options; + + my %options; + my $options_list = Bugzilla::Extension::Push::Option->match({ + connector => $self->{_name}, + }); + foreach my $option (@$options_list) { + $options{$option->name} = $option; + } + + # delete options which are no longer valid + foreach my $name (keys %options) { + if (!grep { $_ eq $name } @valid_options) { + $options{$name}->remove_from_db(); + delete $options{$name}; + } + } + + # update options + foreach my $name (keys %options) { + my $option = $options{$name}; + if ($self->option($name)->{type} eq 'password') { + $option->set_value($self->_encrypt($self->{$name})); + } else { + $option->set_value($self->{$name}); + } + $option->update(); + } + + # add missing options + foreach my $name (@valid_options) { + next if exists $options{$name}; + Bugzilla::Extension::Push::Option->create({ + connector => $self->{_name}, + option_name => $name, + option_value => $self->{$name}, + }); + } +} + +sub _remove_invalid_options { + my ($self, $config) = @_; + my @names; + foreach my $rh ($self->options) { + push @names, $rh->{name}; + } + foreach my $name (keys %$config) { + if ($name =~ /^_/ || !grep { $_ eq $name } @names) { + delete $config->{$name}; + } + } +} + +sub _validate_mandatory { + my ($self, $config) = @_; + $self->_remove_invalid_options($config); + + my @missing; + foreach my $option ($self->options) { + next unless $option->{required}; + my $name = $option->{name}; + if (!exists $config->{$name} || !defined($config->{$name}) || $config->{$name} eq '') { + push @missing, $option; + } + } + if (@missing) { + my $connector = $self->{_name}; + @missing = map { $_->{label} } @missing; + if (scalar @missing == 1) { + die "The option '$missing[0]' for the connector '$connector' is mandatory\n"; + } else { + die "The following options for the connector '$connector' are mandatory:\n " + . join("\n ", @missing) . "\n"; + } + } +} + +sub _validate_config { + my ($self, $config) = @_; + $self->_remove_invalid_options($config); + + my @errors; + foreach my $option ($self->options) { + my $name = $option->{name}; + next unless exists $config->{$name} && exists $option->{validate}; + eval { + $option->{validate}->($config->{$name}, $config); + }; + push @errors, $@ if $@; + } + die join("\n", @errors) if @errors; + + if ($self->{_name} ne 'global') { + my $class = 'Bugzilla::Extension::Push::Connector::' . $self->{_name}; + $class->options_validate($config); + } +} + +sub _cipher { + my ($self) = @_; + $self->{_cipher} ||= Crypt::CBC->new( + -key => Bugzilla->localconfig->{'site_wide_secret'}, + -cipher => 'DES_EDE3'); + return $self->{_cipher}; +} + +sub _decrypt { + my ($self, $value) = @_; + my $result; + eval { $result = $self->_cipher->decrypt_hex($value) }; + return $@ ? '' : $result; +} + +sub _encrypt { + my ($self, $value) = @_; + return $self->_cipher->encrypt_hex($value); +} + +1; diff --git a/extensions/Push/lib/Connector.disabled/ServiceNow.pm b/extensions/Push/lib/Connector.disabled/ServiceNow.pm new file mode 100644 index 000000000..832cc9262 --- /dev/null +++ b/extensions/Push/lib/Connector.disabled/ServiceNow.pm @@ -0,0 +1,434 @@ +# 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::Push::Connector::ServiceNow; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Attachment; +use Bugzilla::Bug; +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Serialise; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Field; +use Bugzilla::Mailer; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Util qw(trim trick_taint); +use Email::MIME; +use FileHandle; +use LWP; +use MIME::Base64; +use Net::LDAP; + +use constant SEND_COMPONENTS => ( + { + product => 'mozilla.org', + component => 'Server Operations: Desktop Issues', + }, +); + +sub options { + return ( + { + name => 'bugzilla_user', + label => 'Bugzilla Service-Now User', + type => 'string', + default => 'service.now@bugzilla.tld', + required => 1, + validate => sub { + Bugzilla::User->new({ name => $_[0] }) + || die "Invalid Bugzilla user ($_[0])\n"; + }, + }, + { + name => 'ldap_scheme', + label => 'Mozilla LDAP Scheme', + type => 'select', + values => [ 'LDAP', 'LDAPS' ], + default => 'LDAPS', + required => 1, + }, + { + name => 'ldap_host', + label => 'Mozilla LDAP Host', + type => 'string', + default => '', + required => 1, + }, + { + name => 'ldap_user', + label => 'Mozilla LDAP Bind Username', + type => 'string', + default => '', + required => 1, + }, + { + name => 'ldap_pass', + label => 'Mozilla LDAP Password', + type => 'password', + default => '', + required => 1, + }, + { + name => 'ldap_poll', + label => 'Mozilla LDAP Poll Frequency', + type => 'string', + default => '3', + required => 1, + help => 'minutes', + validate => sub { + $_[0] =~ /\D/ + && die "LDAP Poll Frequency must be an integer\n"; + $_[0] == 0 + && die "LDAP Poll Frequency cannot be less than one minute\n"; + }, + }, + { + name => 'service_now_url', + label => 'Service Now JSON URL', + type => 'string', + default => 'https://mozilladev.service-now.com', + required => 1, + help => "Must start with https:// and end with ?JSON", + validate => sub { + $_[0] =~ m#^https://[^\.\/]+\.service-now\.com\/# + || die "Invalid Service Now JSON URL\n"; + $_[0] =~ m#\?JSON$# + || die "Invalid Service Now JSON URL (must end with ?JSON)\n"; + }, + }, + { + name => 'service_now_user', + label => 'Service Now JSON Username', + type => 'string', + default => '', + required => 1, + }, + { + name => 'service_now_pass', + label => 'Service Now JSON Password', + type => 'password', + default => '', + required => 1, + }, + ); +} + +sub options_validate { + my ($self, $config) = @_; + my $host = $config->{ldap_host}; + trick_taint($host); + my $scheme = lc($config->{ldap_scheme}); + eval { + my $ldap = Net::LDAP->new($host, scheme => $scheme, onerror => 'die', timeout => 5) + or die $!; + $ldap->bind($config->{ldap_user}, password => $config->{ldap_pass}); + }; + if ($@) { + die sprintf("Failed to connect to %s://%s/: %s\n", $scheme, $host, $@); + } +} + +my $_instance; + +sub init { + my ($self) = @_; + $_instance = $self; +} + +sub load_config { + my ($self) = @_; + $self->SUPER::load_config(@_); + $self->{bugzilla_user} ||= Bugzilla::User->new({ name => $self->config->{bugzilla_user} }); +} + +sub should_send { + my ($self, $message) = @_; + + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data) + || return 0; + + # we don't want to send the initial comment in a separate message + # because we inject it into the inital message + if (exists $data->{comment} && $data->{comment}->{number} == 0) { + return 0; + } + + my $target = $data->{event}->{target}; + unless ($target eq 'bug' || $target eq 'comment' || $target eq 'attachment') { + return 0; + } + + # ensure the service-now user can see the bug + if (!$self->{bugzilla_user} || !$self->{bugzilla_user}->is_enabled) { + return 0; + } + $self->{bugzilla_user}->can_see_bug($bug_data->{id}) + || return 0; + + # don't push changes made by the service-now account + $data->{event}->{user}->{id} == $self->{bugzilla_user}->id + && return 0; + + # filter based on the component + my $bug = Bugzilla::Bug->new($bug_data->{id}); + my $send = 0; + foreach my $rh (SEND_COMPONENTS) { + if ($bug->product eq $rh->{product} && $bug->component eq $rh->{component}) { + $send = 1; + last; + } + } + return $send; +} + +sub send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # should_send intiailises bugzilla_user; make sure we return a useful error message + if (!$self->{bugzilla_user}) { + return (PUSH_RESULT_TRANSIENT, "Invalid bugzilla-user (" . $self->config->{bugzilla_user} . ")"); + } + + # load the bug + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data); + my $bug = Bugzilla::Bug->new($bug_data->{id}); + + if ($message->routing_key eq 'bug.create') { + # inject the comment into the data for new bugs + my $comment = shift @{ $bug->comments }; + if ($comment->body ne '') { + $bug_data->{comment} = Bugzilla::Extension::Push::Serialise->instance->object_to_hash($comment, 1); + } + + } elsif ($message->routing_key eq 'attachment.create') { + # inject the attachment payload + my $attachment = Bugzilla::Attachment->new($data->{attachment}->{id}); + $data->{attachment}->{data} = encode_base64($attachment->data); + } + + # map bmo login to ldap login and insert into json payload + $self->_add_ldap_logins($data, {}); + + # flatten json data + $self->_flatten($data); + + # add sysparm_action + $data->{sysparm_action} = 'insert'; + + if ($logger->debugging) { + $logger->debug(to_json(ref($data) ? $data : from_json($data), 1)); + } + + # send to service-now + my $request = HTTP::Request->new(POST => $self->config->{service_now_url}); + $request->content_type('application/json'); + $request->content(to_json($data)); + $request->authorization_basic($self->config->{service_now_user}, $self->config->{service_now_pass}); + + $self->{lwp} ||= LWP::UserAgent->new(agent => Bugzilla->params->{urlbase}); + my $result = $self->{lwp}->request($request); + + # http level errors + if (!$result->is_success) { + # treat these as transient + return (PUSH_RESULT_TRANSIENT, $result->status_line); + } + + # empty response + if (length($result->content) == 0) { + # malformed request, treat as transient to allow code to fix + # may also be misconfiguration on servicenow, also transient + return (PUSH_RESULT_TRANSIENT, "Empty response"); + } + + # json errors + my $result_data; + eval { + $result_data = from_json($result->content); + }; + if ($@) { + return (PUSH_RESULT_TRANSIENT, clean_error($@)); + } + if ($logger->debugging) { + $logger->debug(to_json($result_data, 1)); + } + if (exists $result_data->{error}) { + return (PUSH_RESULT_ERROR, $result_data->{error}); + }; + + # malformed/unexpected json response + if (!exists $result_data->{records} + || ref($result_data->{records}) ne 'ARRAY' + || scalar(@{$result_data->{records}}) == 0 + ) { + return (PUSH_RESULT_ERROR, "Malformed JSON response from ServiceNow: missing or empty 'records' array"); + } + + my $record = $result_data->{records}->[0]; + if (ref($record) ne 'HASH') { + return (PUSH_RESULT_ERROR, "Malformed JSON response from ServiceNow: 'records' array does not contain an object"); + } + + # sys_id is the unique identifier for this action + if (!exists $record->{sys_id} || $record->{sys_id} eq '') { + return (PUSH_RESULT_ERROR, "Malformed JSON response from ServiceNow: 'records object' does not contain a valid sys_id"); + } + + # success + return (PUSH_RESULT_OK, "sys_id: " . $record->{sys_id}); +} + +sub _get_bug_data { + my ($self, $data) = @_; + my $target = $data->{event}->{target}; + if ($target eq 'bug') { + return $data->{bug}; + } elsif (exists $data->{$target}->{bug}) { + return $data->{$target}->{bug}; + } else { + return; + } +} + +sub _flatten { + # service-now expects a flat json object + my ($self, $data) = @_; + + my $target = $data->{event}->{target}; + + # delete unnecessary deep objects + if ($target eq 'comment' || $target eq 'attachment') { + $data->{$target}->{bug_id} = $data->{$target}->{bug}->{id}; + delete $data->{$target}->{bug}; + } + delete $data->{event}->{changes}; + + $self->_flatten_hash($data, $data, 'u'); +} + +sub _flatten_hash { + my ($self, $base_hash, $hash, $prefix) = @_; + foreach my $key (keys %$hash) { + if (ref($hash->{$key}) eq 'HASH') { + $self->_flatten_hash($base_hash, $hash->{$key}, $prefix . "_$key"); + } elsif (ref($hash->{$key}) ne 'ARRAY') { + $base_hash->{$prefix . "_$key"} = $hash->{$key}; + } + delete $hash->{$key}; + } +} + +sub _add_ldap_logins { + my ($self, $rh, $cache) = @_; + if (exists $rh->{login}) { + my $login = $rh->{login}; + $cache->{$login} ||= $self->_bmo_to_ldap($login); + Bugzilla->push_ext->logger->debug("BMO($login) --> LDAP(" . $cache->{$login} . ")"); + $rh->{ldap} = $cache->{$login}; + } + foreach my $key (keys %$rh) { + next unless ref($rh->{$key}) eq 'HASH'; + $self->_add_ldap_logins($rh->{$key}, $cache); + } +} + +sub _bmo_to_ldap { + my ($self, $login) = @_; + my $ldap = $self->_ldap_cache(); + + return '' unless $login =~ /\@mozilla\.(?:com|org)$/; + + foreach my $check ($login, canon_email($login)) { + # check for matching bugmail entry + foreach my $mail (keys %$ldap) { + next unless $ldap->{$mail}{bugmail_canon} eq $check; + return $mail; + } + + # check for matching mail + if (exists $ldap->{$check}) { + return $check; + } + + # check for matching email alias + foreach my $mail (sort keys %$ldap) { + next unless grep { $check eq $_ } @{$ldap->{$mail}{aliases}}; + return $mail; + } + } + + return ''; +} + +sub _ldap_cache { + my ($self) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # cache of all ldap entries; updated infrequently + if (!$self->{ldap_cache_time} || (time) - $self->{ldap_cache_time} > $config->{ldap_poll} * 60) { + $logger->debug('refreshing LDAP cache'); + + my $cache = {}; + + my $host = $config->{ldap_host}; + trick_taint($host); + my $scheme = lc($config->{ldap_scheme}); + my $ldap = Net::LDAP->new($host, scheme => $scheme, onerror => 'die') + or die $!; + $ldap->bind($config->{ldap_user}, password => $config->{ldap_pass}); + foreach my $ldap_base ('o=com,dc=mozilla', 'o=org,dc=mozilla') { + my $result = $ldap->search( + base => $ldap_base, + scope => 'sub', + filter => '(mail=*)', + attrs => ['mail', 'bugzillaEmail', 'emailAlias', 'cn', 'employeeType'], + ); + foreach my $entry ($result->entries) { + my ($name, $bugMail, $mail, $type) = + map { $entry->get_value($_) || '' } + qw(cn bugzillaEmail mail employeeType); + next if $type eq 'DISABLED'; + $mail = lc $mail; + $bugMail = '' if $bugMail !~ /\@/; + $bugMail = trim($bugMail); + if ($bugMail =~ / /) { + $bugMail = (grep { /\@/ } split / /, $bugMail)[0]; + } + $name =~ s/\s+/ /g; + $cache->{$mail}{name} = trim($name); + $cache->{$mail}{bugmail} = $bugMail; + $cache->{$mail}{bugmail_canon} = canon_email($bugMail); + $cache->{$mail}{aliases} = []; + foreach my $alias ( + @{$entry->get_value('emailAlias', asref => 1) || []} + ) { + push @{$cache->{$mail}{aliases}}, canon_email($alias); + } + } + } + + $self->{ldap_cache} = $cache; + $self->{ldap_cache_time} = (time); + } + + return $self->{ldap_cache}; +} + +1; + diff --git a/extensions/Push/lib/Connector/AMQP.pm b/extensions/Push/lib/Connector/AMQP.pm new file mode 100644 index 000000000..851d4febf --- /dev/null +++ b/extensions/Push/lib/Connector/AMQP.pm @@ -0,0 +1,230 @@ +# 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::Push::Connector::AMQP; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Util qw(generate_random_password); +use DateTime; +use Net::RabbitMQ; + +sub init { + my ($self) = @_; + $self->{mq} = 0; + $self->{channel} = 1; + + if ($self->config->{queue}) { + $self->{queue_name} = $self->config->{queue}; + } else { + my $queue_name = Bugzilla->params->{'urlbase'}; + $queue_name =~ s#^https?://##; + $queue_name =~ s#/$#|#; + $queue_name .= generate_random_password(16); + $self->{queue_name} = $queue_name; + } +} + +sub options { + return ( + { + name => 'host', + label => 'AMQP Hostname', + type => 'string', + default => 'localhost', + required => 1, + }, + { + name => 'port', + label => 'AMQP Port', + type => 'string', + default => '5672', + required => 1, + validate => sub { + $_[0] =~ /\D/ && die "Invalid port (must be numeric)\n"; + }, + }, + { + name => 'username', + label => 'Username', + type => 'string', + default => 'guest', + required => 1, + }, + { + name => 'password', + label => 'Password', + type => 'password', + default => 'guest', + required => 1, + }, + { + name => 'vhost', + label => 'Virtual Host', + type => 'string', + default => '/', + required => 1, + }, + { + name => 'exchange', + label => 'Exchange', + type => 'string', + default => '', + required => 1, + }, + { + name => 'queue', + label => 'Queue', + type => 'string', + }, + ); +} + +sub stop { + my ($self) = @_; + if ($self->{mq}) { + Bugzilla->push_ext->logger->debug('AMQP: disconnecting'); + $self->{mq}->disconnect(); + $self->{mq} = 0; + } +} + +sub _connect { + my ($self) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + $self->stop(); + + $logger->debug('AMQP: Connecting to RabbitMQ ' . $config->{host} . ':' . $config->{port}); + my $mq = Net::RabbitMQ->new(); + $mq->connect( + $config->{host}, + { + port => $config->{port}, + user => $config->{username}, + password => $config->{password}, + } + ); + $self->{mq} = $mq; + + $logger->debug('AMQP: Opening channel ' . $self->{channel}); + $self->{mq}->channel_open($self->{channel}); + + $logger->debug('AMQP: Declaring queue ' . $self->{queue_name}); + $self->{mq}->queue_declare( + $self->{channel}, + $self->{queue_name}, + { + passive => 0, + durable => 1, + exclusive => 0, + auto_delete => 0, + }, + ); +} + +sub _bind { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # bind to queue (also acts to verify the connection is still valid) + if ($self->{mq}) { + eval { + $logger->debug('AMQP: binding queue(' . $self->{queue_name} . ') with exchange(' . $config->{exchange} . ')'); + $self->{mq}->queue_bind( + $self->{channel}, + $self->{queue_name}, + $config->{exchange}, + $message->routing_key, + ); + }; + if ($@) { + $logger->debug('AMQP: ' . clean_error($@)); + $self->{mq} = 0; + } + } + +} + +sub should_send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + + my $payload = $message->payload_decoded(); + my $target = $payload->{event}->{target}; + my $is_private = $payload->{$target}->{is_private} ? 1 : 0; + if (!$is_private && exists $payload->{$target}->{bug}) { + $is_private = $payload->{$target}->{bug}->{is_private} ? 1 : 0; + } + + if ($is_private) { + # we only want to push the is_private message from the change_set, as + # this is guaranteed to contain public information only + if ($message->routing_key !~ /\.modify:is_private$/) { + $logger->debug('AMQP: Ignoring private message'); + return 0; + } + $logger->debug('AMQP: Sending change of message to is_private'); + } + return 1; +} + +sub send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # don't push comments to pulse + if ($message->routing_key =~ /^comment\./) { + $logger->debug('AMQP: Ignoring comment'); + return PUSH_RESULT_IGNORED; + } + + # don't push private data + $self->should_push($message) + || return PUSH_RESULT_IGNORED; + + $self->_bind($message); + + eval { + # reconnect if required + if (!$self->{mq}) { + $self->_connect(); + } + + # send message + $logger->debug('AMQP: Publishing message'); + $self->{mq}->publish( + $self->{channel}, + $message->routing_key, + $message->payload, + { + exchange => $config->{exchange}, + }, + { + content_type => 'text/plain', + content_encoding => '8bit', + }, + ); + }; + if ($@) { + return (PUSH_RESULT_TRANSIENT, clean_error($@)); + } + + return PUSH_RESULT_OK; +} + +1; + diff --git a/extensions/Push/lib/Connector/Base.pm b/extensions/Push/lib/Connector/Base.pm new file mode 100644 index 000000000..290ea9740 --- /dev/null +++ b/extensions/Push/lib/Connector/Base.pm @@ -0,0 +1,106 @@ +# 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::Push::Connector::Base; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::Config; +use Bugzilla::Extension::Push::BacklogMessage; +use Bugzilla::Extension::Push::BacklogQueue; +use Bugzilla::Extension::Push::Backoff; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + ($self->{name}) = $class =~ /^.+:(.+)$/; + $self->init(); + return $self; +} + +sub name { + my $self = shift; + return $self->{name}; +} + +sub init { + my ($self) = @_; + # abstract + # perform any initialisation here + # will be run when created by the web pages or by the daemon + # and also when the configuration needs to be reloaded +} + +sub stop { + my ($self) = @_; + # abstract + # run from the daemon only; disconnect from remote hosts, etc +} + +sub should_send { + my ($self, $message) = @_; + # abstract + # return boolean indicating if the connector will be sending the message. + # this will be called each message, and should be a very quick simple test. + # the connector can perform a more exhaustive test in the send() method. + return 0; +} + +sub send { + my ($self, $message) = @_; + # abstract + # deliver the message, daemon only +} + +sub options { + my ($self) = @_; + # abstract + # return an array of configuration variables + return (); +} + +sub options_validate { + my ($class, $config) = @_; + # abstract, static + # die if a combination of options in $config is invalid +} + +# +# +# + +sub config { + my ($self) = @_; + if (!$self->{config}) { + $self->load_config(); + } + return $self->{config}; +} + +sub load_config { + my ($self) = @_; + my $config = Bugzilla::Extension::Push::Config->new($self->name, $self->options); + $config->load(); + $self->{config} = $config; +} + +sub enabled { + my ($self) = @_; + return $self->config->{enabled} eq 'Enabled'; +} + +sub backlog { + my ($self) = @_; + $self->{backlog} ||= Bugzilla::Extension::Push::BacklogQueue->new($self->name); + return $self->{backlog}; +} + +1; + diff --git a/extensions/Push/lib/Connector/File.pm b/extensions/Push/lib/Connector/File.pm new file mode 100644 index 000000000..2a8f4193d --- /dev/null +++ b/extensions/Push/lib/Connector/File.pm @@ -0,0 +1,68 @@ +# 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::Push::Connector::File; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Util; +use Encode; +use FileHandle; + +sub init { + my ($self) = @_; +} + +sub options { + return ( + { + name => 'filename', + label => 'Filename', + type => 'string', + default => 'push.log', + required => 1, + validate => sub { + my $filename = shift; + $filename =~ m#^/# + && die "Absolute paths are not permitted\n"; + }, + }, + ); +} + +sub should_send { + my ($self, $message) = @_; + return 1; +} + +sub send { + my ($self, $message) = @_; + + # pretty-format json payload + my $payload = $message->payload_decoded; + $payload = to_json($payload, 1); + + my $filename = bz_locations()->{'datadir'} . '/' . $self->config->{filename}; + Bugzilla->push_ext->logger->debug("File: Appending to $filename"); + my $fh = FileHandle->new(">>$filename"); + $fh->binmode(':utf8'); + $fh->print( + "[" . scalar(localtime) . "]\n" . + $payload . "\n\n" + ); + $fh->close; + + return PUSH_RESULT_OK; +} + +1; + diff --git a/extensions/Push/lib/Connector/TCL.pm b/extensions/Push/lib/Connector/TCL.pm new file mode 100644 index 000000000..b6e531b8f --- /dev/null +++ b/extensions/Push/lib/Connector/TCL.pm @@ -0,0 +1,241 @@ +# 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::Push::Connector::TCL; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Serialise; +use Bugzilla::Extension::Push::Util; +use Bugzilla::User; + +use Digest::MD5 qw(md5_hex); +use File::Temp; + +sub options { + return ( + { + name => 'tcl_user', + label => 'Bugzilla TCL User', + type => 'string', + default => 'tcl@bugzilla.tld', + required => 1, + validate => sub { + Bugzilla::User->new({ name => $_[0] }) + || die "Invalid Bugzilla user ($_[0])\n"; + }, + }, + { + name => 'sftp_host', + label => 'SFTP Host', + type => 'string', + default => '', + required => 1, + }, + { + name => 'sftp_port', + label => 'SFTP Port', + type => 'string', + default => '22', + required => 1, + validate => sub { + $_[0] =~ /\D/ && die "SFTP Port must be an integer\n"; + }, + }, + { + name => 'sftp_user', + label => 'SFTP Username', + type => 'string', + default => '', + required => 1, + }, + { + name => 'sftp_pass', + label => 'SFTP Password', + type => 'password', + default => '', + required => 1, + }, + { + name => 'sftp_remote_path', + label => 'SFTP Remote Path', + type => 'string', + default => '', + required => 0, + }, + ); +} + +my $_instance; + +sub init { + my ($self) = @_; + $_instance = $self; +} + +sub load_config { + my ($self) = @_; + $self->SUPER::load_config(@_); +} + +sub should_send { + my ($self, $message) = @_; + + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data) + || return 0; + + # sanity check user + $self->{tcl_user} ||= Bugzilla::User->new({ name => $self->config->{tcl_user} }); + if (!$self->{tcl_user} || !$self->{tcl_user}->is_enabled) { + return 0; + } + + # only send bugs created by the tcl user + unless ($bug_data->{reporter}->{id} == $self->{tcl_user}->id) { + return 0; + } + + # don't push changes made by the tcl user + if ($data->{event}->{user}->{id} == $self->{tcl_user}->id) { + return 0; + } + + # send comments + if ($data->{event}->{routing_key} eq 'comment.create') { + return 0 if $data->{comment}->{is_private}; + return 1; + } + + # send status and resolution updates + foreach my $change (@{ $data->{event}->{changes} }) { + return 1 if $change->{field} eq 'bug_status' || $change->{field} eq 'resolution'; + } + + # and nothing else + return 0; +} + +sub send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + require XML::Simple; + require Net::SFTP; + + $self->{tcl_user} ||= Bugzilla::User->new({ name => $self->config->{tcl_user} }); + if (!$self->{tcl_user}) { + return (PUSH_RESULT_TRANSIENT, "Invalid bugzilla-user (" . $self->config->{tcl_user} . ")"); + } + + # load the bug + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data); + + # build payload + my %xml = ( + Mozilla_ID => $bug_data->{id}, + When => $data->{event}->{time}, + Who => $data->{event}->{user}->{login}, + Status => $bug_data->{status}->{name}, + Resolution => $bug_data->{resolution}, + ); + if ($data->{event}->{routing_key} eq 'comment.create') { + $xml{Comment} = $data->{comment}->{body}; + } + + # convert to xml + my $xml = XML::Simple::XMLout( + \%xml, + NoAttr => 1, + RootName => 'sync', + XMLDecl => 1, + ); + + # generate md5 + my $md5 = md5_hex($xml); + + # build filename + my ($sec, $min, $hour, $day, $mon, $year) = localtime(time); + my $change_set = $data->{event}->{change_set}; + $change_set =~ s/\.//g; + my $filename = sprintf( + '%04s%02d%02d%02d%02d%02d%s', + $year + 1900, + $mon + 1, + $day, + $hour, + $min, + $sec, + $change_set, + ); + + # create temp files; + my $temp_dir = File::Temp->newdir(); + my $local_dir = $temp_dir->dirname; + _write_file("$local_dir/$filename.sync", $xml); + _write_file("$local_dir/$filename.sync.check", $md5); + _write_file("$local_dir/$filename.done", ''); + + my $remote_dir = $self->config->{sftp_remote_path} eq '' + ? '' + : $self->config->{sftp_remote_path} . '/'; + + # send files via sftp + $logger->debug("Connecting to " . $self->config->{sftp_host} . ":" . $self->config->{sftp_port}); + my $sftp = Net::SFTP->new( + $self->config->{sftp_host}, + ssh_args => { + port => $self->config->{sftp_port}, + }, + user => $self->config->{sftp_user}, + password => $self->config->{sftp_pass}, + ); + + $logger->debug("Uploading $local_dir/$filename.add"); + $sftp->put("$local_dir/$filename.add", "$remote_dir$filename.add") + or return (PUSH_RESULT_ERROR, "Failed to upload $local_dir/$filename.add"); + + $logger->debug("Uploading $local_dir/$filename.add.check"); + $sftp->put("$local_dir/$filename.add.check", "$remote_dir$filename.add.check") + or return (PUSH_RESULT_ERROR, "Failed to upload $local_dir/$filename.add.check"); + + $logger->debug("Uploading $local_dir/$filename.done"); + $sftp->put("$local_dir/$filename.done", "$remote_dir$filename.done") + or return (PUSH_RESULT_ERROR, "Failed to upload $local_dir/$filename.done"); + + # success + return (PUSH_RESULT_OK, "uploaded $filename.add"); +} + +sub _get_bug_data { + my ($self, $data) = @_; + my $target = $data->{event}->{target}; + if ($target eq 'bug') { + return $data->{bug}; + } elsif (exists $data->{$target}->{bug}) { + return $data->{$target}->{bug}; + } else { + return; + } +} + +sub _write_file { + my ($filename, $content) = @_; + open(my $fh, ">$filename") or die "Failed to write to $filename: $!\n"; + print $fh $content; + close($fh) or die "Failed to write to $filename: $!\n"; +} + +1; + diff --git a/extensions/Push/lib/Connectors.pm b/extensions/Push/lib/Connectors.pm new file mode 100644 index 000000000..e765b4a43 --- /dev/null +++ b/extensions/Push/lib/Connectors.pm @@ -0,0 +1,115 @@ +# 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::Push::Connectors; + +use strict; +use warnings; + +use Bugzilla::Extension::Push::Util; +use Bugzilla::Constants; +use Bugzilla::Util qw(trick_taint); +use File::Basename; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + + $self->{names} = []; + $self->{objects} = {}; + $self->{path} = bz_locations->{'extensionsdir'} . '/Push/lib/Connector'; + + my $logger = Bugzilla->push_ext->logger; + foreach my $file (glob($self->{path} . '/*.pm')) { + my $name = basename($file); + $name =~ s/\.pm$//; + next if $name eq 'Base'; + if (length($name) > 32) { + $logger->info("Ignoring connector '$name': Name longer than 32 characters"); + } + push @{$self->{names}}, $name; + $logger->debug("Found connector '$name'"); + } + + return $self; +} + +sub _load { + my ($self) = @_; + return if scalar keys %{$self->{objects}}; + + my $logger = Bugzilla->push_ext->logger; + foreach my $name (@{$self->{names}}) { + next if exists $self->{objects}->{$name}; + my $file = $self->{path} . "/$name.pm"; + trick_taint($file); + require $file; + my $package = "Bugzilla::Extension::Push::Connector::$name"; + + $logger->debug("Loading connector '$name'"); + my $old_error_mode = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + eval { + my $connector = $package->new(); + $connector->load_config(); + $self->{objects}->{$name} = $connector; + }; + if ($@) { + $logger->error("Connector '$name' failed to load: " . clean_error($@)); + } + Bugzilla->error_mode($old_error_mode); + } +} + +sub stop { + my ($self) = @_; + my $logger = Bugzilla->push_ext->logger; + foreach my $connector ($self->list) { + next unless $connector->enabled; + $logger->debug("Stopping '" . $connector->name . "'"); + eval { + $connector->stop(); + }; + if ($@) { + $logger->error("Connector '" . $connector->name . "' failed to stop: " . clean_error($@)); + $logger->debug("Connector '" . $connector->name . "' failed to stop: $@"); + } + } +} + +sub reload { + my ($self) = @_; + $self->stop(); + $self->{objects} = {}; + $self->_load(); +} + +sub names { + my ($self) = @_; + return @{$self->{names}}; +} + +sub list { + my ($self) = @_; + $self->_load(); + return sort { $a->name cmp $b->name } values %{$self->{objects}}; +} + +sub exists { + my ($self, $name) = @_; + $self->by_name($name) ? 1 : 0; +} + +sub by_name { + my ($self, $name) = @_; + return unless exists $self->{objects}->{$name}; + return $self->{objects}->{$name}; +} + +1; + diff --git a/extensions/Push/lib/Constants.pm b/extensions/Push/lib/Constants.pm new file mode 100644 index 000000000..18b12d511 --- /dev/null +++ b/extensions/Push/lib/Constants.pm @@ -0,0 +1,41 @@ +# 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::Push::Constants; + +use strict; +use base 'Exporter'; + +our @EXPORT = qw( + PUSH_RESULT_OK + PUSH_RESULT_IGNORED + PUSH_RESULT_TRANSIENT + PUSH_RESULT_ERROR + PUSH_RESULT_UNKNOWN + push_result_to_string + + POLL_INTERVAL_SECONDS +); + +use constant PUSH_RESULT_OK => 1; +use constant PUSH_RESULT_IGNORED => 2; +use constant PUSH_RESULT_TRANSIENT => 3; +use constant PUSH_RESULT_ERROR => 4; +use constant PUSH_RESULT_UNKNOWN => 5; + +sub push_result_to_string { + my ($result) = @_; + return 'OK' if $result == PUSH_RESULT_OK; + return 'OK-IGNORED' if $result == PUSH_RESULT_IGNORED; + return 'TRANSIENT-ERROR' if $result == PUSH_RESULT_TRANSIENT; + return 'FATAL-ERROR' if $result == PUSH_RESULT_ERROR; + return 'UNKNOWN' if $result == PUSH_RESULT_UNKNOWN; +} + +use constant POLL_INTERVAL_SECONDS => 30; + +1; diff --git a/extensions/Push/lib/Daemon.pm b/extensions/Push/lib/Daemon.pm new file mode 100644 index 000000000..66e15783e --- /dev/null +++ b/extensions/Push/lib/Daemon.pm @@ -0,0 +1,96 @@ +# 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::Push::Daemon; + +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Push; +use Bugzilla::Extension::Push::Logger; +use Carp qw(confess); +use Daemon::Generic; +use File::Basename; +use Pod::Usage; + +sub start { + newdaemon(); +} + +# +# daemon::generic config +# + +sub gd_preconfig { + my $self = shift; + my $pidfile = $self->{gd_args}{pidfile}; + if (!$pidfile) { + $pidfile = bz_locations()->{datadir} . '/' . $self->{gd_progname} . ".pid"; + } + return (pidfile => $pidfile); +} + +sub gd_getopt { + my $self = shift; + $self->SUPER::gd_getopt(); + if ($self->{gd_args}{progname}) { + $self->{gd_progname} = $self->{gd_args}{progname}; + } else { + $self->{gd_progname} = basename($0); + } + $self->{_original_zero} = $0; + $0 = $self->{gd_progname}; +} + +sub gd_postconfig { + my $self = shift; + $0 = delete $self->{_original_zero}; +} + +sub gd_more_opt { + my $self = shift; + return ( + 'pidfile=s' => \$self->{gd_args}{pidfile}, + 'n=s' => \$self->{gd_args}{progname}, + ); +} + +sub gd_usage { + pod2usage({ -verbose => 0, -exitval => 'NOEXIT' }); + return 0; +}; + +sub gd_redirect_output { + my $self = shift; + + my $filename = bz_locations()->{datadir} . '/' . $self->{gd_progname} . ".log"; + open(STDERR, ">>$filename") or (print "could not open stderr: $!" && exit(1)); + close(STDOUT); + open(STDOUT, ">&STDERR") or die "redirect STDOUT -> STDERR: $!"; + $SIG{HUP} = sub { + close(STDERR); + open(STDERR, ">>$filename") or (print "could not open stderr: $!" && exit(1)); + }; +} + +sub gd_setup_signals { + my $self = shift; + $self->SUPER::gd_setup_signals(); + $SIG{TERM} = sub { $self->gd_quit_event(); } +} + +sub gd_run { + my $self = shift; + $::SIG{__DIE__} = \&Carp::confess if $self->{debug}; + my $push = Bugzilla->push_ext; + $push->logger->{debug} = $self->{debug}; + $push->is_daemon(1); + $push->start(); +} + +1; diff --git a/extensions/Push/lib/Log.pm b/extensions/Push/lib/Log.pm new file mode 100644 index 000000000..6faabea97 --- /dev/null +++ b/extensions/Push/lib/Log.pm @@ -0,0 +1,45 @@ +# 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::Push::Log; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::Message; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +sub count { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return $dbh->selectrow_array("SELECT COUNT(*) FROM push_log"); +} + +sub list { + my ($self, %args) = @_; + $args{limit} ||= 10; + $args{filter} ||= ''; + my @result; + my $dbh = Bugzilla->dbh; + + my $ids = $dbh->selectcol_arrayref(" + SELECT id + FROM push_log + ORDER BY processed_ts DESC " . + $dbh->sql_limit(100) + ); + return Bugzilla::Extension::Push::LogEntry->new_from_list($ids); +} + +1; diff --git a/extensions/Push/lib/LogEntry.pm b/extensions/Push/lib/LogEntry.pm new file mode 100644 index 000000000..b883ee095 --- /dev/null +++ b/extensions/Push/lib/LogEntry.pm @@ -0,0 +1,66 @@ +# 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::Push::LogEntry; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Constants; + +# +# initialisation +# + +use constant DB_TABLE => 'push_log'; +use constant DB_COLUMNS => qw( + id + message_id + change_set + routing_key + connector + push_ts + processed_ts + result + data +); +use constant VALIDATORS => { + data => \&_check_data, +}; +use constant NAME_FIELD => ''; +use constant LIST_ORDER => 'processed_ts DESC'; + +# +# accessors +# + +sub message_id { return $_[0]->{'message_id'}; } +sub change_set { return $_[0]->{'change_set'}; } +sub routing_key { return $_[0]->{'routing_key'}; } +sub connector { return $_[0]->{'connector'}; } +sub push_ts { return $_[0]->{'push_ts'}; } +sub processed_ts { return $_[0]->{'processed_ts'}; } +sub result { return $_[0]->{'result'}; } +sub data { return $_[0]->{'data'}; } + +sub result_string { return push_result_to_string($_[0]->result) } + +# +# validators +# + +sub _check_data { + my ($invocant, $value) = @_; + return $value eq '' ? undef : $value; +} + +1; + diff --git a/extensions/Push/lib/Logger.pm b/extensions/Push/lib/Logger.pm new file mode 100644 index 000000000..68cec1e69 --- /dev/null +++ b/extensions/Push/lib/Logger.pm @@ -0,0 +1,70 @@ +# 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::Push::Logger; + +use strict; +use warnings; + +use Apache2::Log; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::LogEntry; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +sub info { shift->_log_it('INFO', @_) } +sub error { shift->_log_it('ERROR', @_) } +sub debug { shift->_log_it('DEBUG', @_) } + +sub debugging { + my ($self) = @_; + return $self->{debug}; +} + +sub _log_it { + my ($self, $method, $message) = @_; + return if $method eq 'DEBUG' && !$self->debugging; + chomp $message; + if ($ENV{MOD_PERL}) { + Apache2::ServerRec::warn("Push $method: $message"); + } elsif ($ENV{SCRIPT_FILENAME}) { + print STDERR "Push $method: $message\n"; + } else { + print STDERR '[' . localtime(time) ."] $method: $message\n"; + } +} + +sub result { + my ($self, $connector, $message, $result, $data) = @_; + $data ||= ''; + + $self->info(sprintf( + "%s: Message #%s: %s %s", + $connector->name, + $message->message_id, + push_result_to_string($result), + $data + )); + + Bugzilla::Extension::Push::LogEntry->create({ + message_id => $message->message_id, + change_set => $message->change_set, + routing_key => $message->routing_key, + connector => $connector->name, + push_ts => $message->push_ts, + processed_ts => Bugzilla->dbh->selectrow_array('SELECT NOW()'), + result => $result, + data => $data, + }); +} + +1; diff --git a/extensions/Push/lib/Message.pm b/extensions/Push/lib/Message.pm new file mode 100644 index 000000000..3d112a2e1 --- /dev/null +++ b/extensions/Push/lib/Message.pm @@ -0,0 +1,99 @@ +# 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::Push::Message; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Util; +use Encode; + +# +# initialisation +# + +use constant DB_TABLE => 'push'; +use constant DB_COLUMNS => qw( + id + push_ts + payload + change_set + routing_key +); +use constant LIST_ORDER => 'push_ts'; +use constant VALIDATORS => { + push_ts => \&_check_push_ts, + payload => \&_check_payload, + change_set => \&_check_change_set, + routing_key => \&_check_routing_key, +}; + +# this creates an object which doesn't exist on the database +sub new_transient { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my $object = shift; + bless($object, $class) if $object; + return $object; +} + +# take a transient object and commit +sub create_from_transient { + my ($self) = @_; + return $self->create($self); +} + +# +# accessors +# + +sub push_ts { return $_[0]->{'push_ts'}; } +sub payload { return $_[0]->{'payload'}; } +sub change_set { return $_[0]->{'change_set'}; } +sub routing_key { return $_[0]->{'routing_key'}; } +sub message_id { return $_[0]->id; } + +sub payload_decoded { + my ($self) = @_; + return from_json($self->{'payload'}); +} + +# +# validators +# + +sub _check_push_ts { + my ($invocant, $value) = @_; + $value ||= Bugzilla->dbh->selectrow_array('SELECT NOW()'); + return $value; +} + +sub _check_payload { + my ($invocant, $value) = @_; + length($value) || ThrowCodeError('push_invalid_payload'); + return $value; +} + +sub _check_change_set { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_change_set'); + return $value; +} + +sub _check_routing_key { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_routing_key'); + return $value; +} + +1; + diff --git a/extensions/Push/lib/Option.pm b/extensions/Push/lib/Option.pm new file mode 100644 index 000000000..25d529f98 --- /dev/null +++ b/extensions/Push/lib/Option.pm @@ -0,0 +1,66 @@ +# 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::Push::Option; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Util; + +# +# initialisation +# + +use constant DB_TABLE => 'push_options'; +use constant DB_COLUMNS => qw( + id + connector + option_name + option_value +); +use constant UPDATE_COLUMNS => qw( + option_value +); +use constant VALIDATORS => { + connector => \&_check_connector, +}; +use constant LIST_ORDER => 'connector'; + +# +# accessors +# + +sub connector { return $_[0]->{'connector'}; } +sub name { return $_[0]->{'option_name'}; } +sub value { return $_[0]->{'option_value'}; } + +# +# mutators +# + +sub set_value { $_[0]->{'option_value'} = $_[1]; } + +# +# validators +# + +sub _check_connector { + my ($invocant, $value) = @_; + $value eq '*' + || $value eq 'global' + || Bugzilla->push_ext->connectors->exists($value) + || ThrowCodeError('push_invalid_connector'); + return $value; +} + +1; + diff --git a/extensions/Push/lib/Push.pm b/extensions/Push/lib/Push.pm new file mode 100644 index 000000000..76b82dda4 --- /dev/null +++ b/extensions/Push/lib/Push.pm @@ -0,0 +1,249 @@ +# 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::Push::Push; + +use strict; +use warnings; + +use Bugzilla::Extension::Push::BacklogMessage; +use Bugzilla::Extension::Push::Config; +use Bugzilla::Extension::Push::Connectors; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Log; +use Bugzilla::Extension::Push::Logger; +use Bugzilla::Extension::Push::Message; +use Bugzilla::Extension::Push::Option; +use Bugzilla::Extension::Push::Queue; +use Bugzilla::Extension::Push::Util; +use DateTime; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + $self->{is_daemon} = 0; + return $self; +} + +sub is_daemon { + my ($self, $value) = @_; + if (defined $value) { + $self->{is_daemon} = $value ? 1 : 0; + } + return $self->{is_daemon}; +} + +sub start { + my ($self) = @_; + my $connectors = $self->connectors; + $self->{config_last_modified} = $self->get_config_last_modified(); + $self->{config_last_checked} = (time); + + foreach my $connector ($connectors->list) { + $connector->backlog->reset_backoff(); + } + + while(1) { + $self->_reload(); + $self->push(); + sleep(POLL_INTERVAL_SECONDS); + } +} + +sub push { + my ($self) = @_; + my $logger = $self->logger; + my $connectors = $self->connectors; + + my $enabled = 0; + foreach my $connector ($connectors->list) { + if ($connector->enabled) { + $enabled = 1; + last; + } + } + return unless $enabled; + + $logger->debug("polling"); + + # process each message + while(my $message = $self->queue->oldest) { + foreach my $connector ($connectors->list) { + next unless $connector->enabled; + next unless $connector->should_send($message); + $logger->debug("pushing to " . $connector->name); + + my $is_backlogged = $connector->backlog->count; + + if (!$is_backlogged) { + # connector isn't backlogged, immediate send + $logger->debug("immediate send"); + my ($result, $data); + eval { + ($result, $data) = $connector->send($message); + }; + if ($@) { + $result = PUSH_RESULT_TRANSIENT; + $data = clean_error($@); + } + if (!$result) { + $logger->error($connector->name . " failed to return a result code"); + $result = PUSH_RESULT_UNKNOWN; + } + $logger->result($connector, $message, $result, $data); + + if ($result == PUSH_RESULT_TRANSIENT) { + $is_backlogged = 1; + } + } + + # if the connector is backlogged, push to the backlog queue + if ($is_backlogged) { + $logger->debug("backlogged"); + my $backlog = Bugzilla::Extension::Push::BacklogMessage->create_from_message($message, $connector); + } + } + + # message processed + $message->remove_from_db(); + } + + # process backlog + foreach my $connector ($connectors->list) { + next unless $connector->enabled; + my $message = $connector->backlog->oldest(); + next unless $message; + + $logger->debug("processing backlog for " . $connector->name); + while ($message) { + my ($result, $data); + eval { + ($result, $data) = $connector->send($message); + }; + if ($@) { + $result = PUSH_RESULT_TRANSIENT; + $data = $@; + } + $message->inc_attempts($result == PUSH_RESULT_OK ? '' : $data); + if (!$result) { + $logger->error($connector->name . " failed to return a result code"); + $result = PUSH_RESULT_UNKNOWN; + } + $logger->result($connector, $message, $result, $data); + + if ($result == PUSH_RESULT_TRANSIENT) { + # connector is still down, stop trying + $connector->backlog->inc_backoff(); + last; + } + + # message was processed + $message->remove_from_db(); + + $message = $connector->backlog->oldest(); + } + } +} + +sub _reload { + my ($self) = @_; + + # check for updated config every 60 seconds + my $now = (time); + if ($now - $self->{config_last_checked} < 60) { + return; + } + $self->{config_last_checked} = $now; + + $self->logger->debug('Checking for updated configuration'); + if ($self->get_config_last_modified eq $self->{config_last_modified}) { + return; + } + $self->{config_last_modified} = $self->get_config_last_modified(); + + $self->logger->debug('Configuration has been updated'); + $self->connectors->reload(); +} + +sub get_config_last_modified { + my ($self) = @_; + my $options_list = Bugzilla::Extension::Push::Option->match({ + connector => '*', + option_name => 'last-modified', + }); + if (@$options_list) { + return $options_list->[0]->value; + } else { + return $self->set_config_last_modified(); + } +} + +sub set_config_last_modified { + my ($self) = @_; + my $options_list = Bugzilla::Extension::Push::Option->match({ + connector => '*', + option_name => 'last-modified', + }); + my $now = DateTime->now->datetime(); + if (@$options_list) { + $options_list->[0]->set_value($now); + $options_list->[0]->update(); + } else { + Bugzilla::Extension::Push::Option->create({ + connector => '*', + option_name => 'last-modified', + option_value => $now, + }); + } + return $now; +} + +sub config { + my ($self) = @_; + if (!$self->{config}) { + $self->{config} = Bugzilla::Extension::Push::Config->new( + 'global', + { + name => 'log_purge', + label => 'Purge logs older than (days)', + type => 'string', + default => '7', + required => '1', + validate => sub { $_[0] =~ /\D/ && die "Invalid purge duration (must be numeric)\n"; }, + }, + ); + $self->{config}->load(); + } + return $self->{config}; +} + +sub logger { + my ($self, $value) = @_; + $self->{logger} = $value if $value; + return $self->{logger}; +} + +sub connectors { + my ($self, $value) = @_; + $self->{connectors} = $value if $value; + return $self->{connectors}; +} + +sub queue { + my ($self) = @_; + $self->{queue} ||= Bugzilla::Extension::Push::Queue->new(); + return $self->{queue}; +} + +sub log { + my ($self) = @_; + $self->{log} ||= Bugzilla::Extension::Push::Log->new(); + return $self->{log}; +} + +1; diff --git a/extensions/Push/lib/Queue.pm b/extensions/Push/lib/Queue.pm new file mode 100644 index 000000000..d89cb23c3 --- /dev/null +++ b/extensions/Push/lib/Queue.pm @@ -0,0 +1,72 @@ +# 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::Push::Queue; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::Message; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +sub count { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return $dbh->selectrow_array("SELECT COUNT(*) FROM push"); +} + +sub oldest { + my ($self) = @_; + my @messages = $self->list(limit => 1); + return scalar(@messages) ? $messages[0] : undef; +} + +sub by_id { + my ($self, $id) = @_; + my @messages = $self->list( + limit => 1, + filter => "AND (push.id = $id)", + ); + return scalar(@messages) ? $messages[0] : undef; +} + +sub list { + my ($self, %args) = @_; + $args{limit} ||= 10; + $args{filter} ||= ''; + my @result; + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare(" + SELECT id, push_ts, payload, change_set, routing_key + FROM push + WHERE (1 = 1) " . + $args{filter} . " + ORDER BY push_ts " . + $dbh->sql_limit($args{limit}) + ); + $sth->execute(); + while (my $row = $sth->fetchrow_hashref()) { + push @result, Bugzilla::Extension::Push::Message->new({ + id => $row->{id}, + push_ts => $row->{push_ts}, + payload => $row->{payload}, + change_set => $row->{change_set}, + routing_key => $row->{routing_key}, + }); + } + return @result; +} + +1; diff --git a/extensions/Push/lib/Serialise.pm b/extensions/Push/lib/Serialise.pm new file mode 100644 index 000000000..ad1cc0452 --- /dev/null +++ b/extensions/Push/lib/Serialise.pm @@ -0,0 +1,318 @@ +# 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::Push::Serialise; + +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Version; + +use Scalar::Util 'blessed'; +use JSON (); + +my $_instance; +sub instance { + $_instance ||= Bugzilla::Extension::Push::Serialise->_new(); + return $_instance; +} + +sub _new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +# given an object, serliase to a hash +sub object_to_hash { + my ($self, $object, $is_shallow) = @_; + + my $method = lc(blessed($object)); + $method =~ s/::/_/g; + $method =~ s/^bugzilla//; + return unless $self->can($method); + (my $name = $method) =~ s/^_//; + + # check for a cached hash + my $cache = Bugzilla->request_cache; + my $cache_id = "push." . ($is_shallow ? 'shallow.' : 'deep.') . $object; + if (exists($cache->{$cache_id})) { + return wantarray ? ($cache->{$cache_id}, $name) : $cache->{$cache_id}; + } + + # call the right method to serialise to a hash + my $rh = $self->$method($object, $is_shallow); + + # store in cache + if ($cache_id) { + $cache->{$cache_id} = $rh; + } + + return wantarray ? ($rh, $name) : $rh; +} + +# given a changes hash, return an event hash +sub changes_to_event { + my ($self, $changes) = @_; + + my $event = {}; + + # create common (created and modified) fields + $event->{'user'} = $self->object_to_hash(Bugzilla->user); + my $timestamp = + $changes->{'timestamp'} + || Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + $event->{'time'} = datetime_to_timestamp($timestamp); + + foreach my $change (@{$changes->{'changes'}}) { + if (exists $change->{'field'}) { + # map undef to emtpy + hash_undef_to_empty($change); + + # custom_fields change from undef to empty, ignore these changes + return if ($change->{'added'} || "") eq "" && + ($change->{'removed'} || "") eq ""; + + # use saner field serialisation + my $field = $change->{'field'}; + $change->{'field'} = $field; + + if ($field eq 'priority' || $field eq 'target_milestone') { + $change->{'added'} = _select($change->{'added'}); + $change->{'removed'} = _select($change->{'removed'}); + + } elsif ($field =~ /^cf_/) { + $change->{'added'} = _custom_field($field, $change->{'added'}); + $change->{'removed'} = _custom_field($field, $change->{'removed'}); + } + + $event->{'changes'} = [] unless exists $event->{'changes'}; + push @{$event->{'changes'}}, $change; + } + } + + return $event; +} + +# bugzilla returns '---' or '--' for single-select fields that have no value +# selected. it makes more sense to return an empty string. +sub _select { + my ($value) = @_; + return '' if $value eq '---' or $value eq '--'; + return $value; +} + +# return an object which serialises to a json boolean, but still acts as a perl +# boolean +sub _boolean { + my ($value) = @_; + return $value ? JSON::true : JSON::false; +} + +sub _string { + my ($value) = @_; + return defined($value) ? $value : ''; +} + +sub _time { + my ($value) = @_; + return defined($value) ? datetime_to_timestamp($value) : undef; +} + +sub _integer { + my ($value) = @_; + return $value + 0; +} + +sub _custom_field { + my ($field, $value) = @_; + $field = Bugzilla::Field->new({ name => $field }) unless blessed $field; + + if ($field->type == FIELD_TYPE_DATETIME) { + return _time($value); + + } elsif ($field->type == FIELD_TYPE_SINGLE_SELECT) { + return _select($value); + + } elsif ($field->type == FIELD_TYPE_MULTI_SELECT) { + # XXX + die "not implemented"; + + } else { + return _string($value); + } +} + +# +# class mappings +# automatically derrived from the class name +# Bugzilla::Bug --> _bug, Bugzilla::User --> _user, etc +# + +sub _bug { + my ($self, $bug) = @_; + + my $version = $bug->can('version_obj') + ? $bug->version_obj + : Bugzilla::Version->new({ name => $bug->version, product => $bug->product_obj }); + + my $milestone; + if (_select($bug->target_milestone) ne '') { + $milestone = $bug->can('target_milestone_obj') + ? $bug->target_milestone_obj + : Bugzilla::Milestone->new({ name => $bug->target_milestone, product => $bug->product_obj }); + } + + my $status = $bug->can('status_obj') + ? $bug->status_obj + : Bugzilla::Status->new({ name => $bug->bug_status }); + + my $rh = { + id => _integer($bug->bug_id), + alias => _string($bug->alias), + assigned_to => $self->_user($bug->assigned_to), + classification => _string($bug->classification), + component => $self->_component($bug->component_obj), + creation_time => _time($bug->creation_ts || $bug->delta_ts), + flags => (mapr { $self->_flag($_) } $bug->flags), + is_private => _boolean(!is_public($bug)), + keywords => (mapr { _string($_->name) } $bug->keyword_objects), + last_change_time => _time($bug->delta_ts), + operating_system => _string($bug->op_sys), + platform => _string($bug->rep_platform), + priority => _select($bug->priority), + product => $self->_product($bug->product_obj), + qa_contact => $self->_user($bug->qa_contact), + reporter => $self->_user($bug->reporter), + resolution => _string($bug->resolution), + severity => _string($bug->bug_severity), + status => $self->_status($status), + summary => _string($bug->short_desc), + target_milestone => $self->_milestone($milestone), + url => _string($bug->bug_file_loc), + version => $self->_version($version), + whiteboard => _string($bug->status_whiteboard), + }; + + # add custom fields + my @custom_fields = Bugzilla->active_custom_fields; + foreach my $field (@custom_fields) { + my $name = $field->name; + + # skip custom fields that are hidded from this product/component + next if Bugzilla::Extension::BMO::cf_hidden_in_product( + $name, $bug->product, $bug->component); + + $rh->{$name} = _custom_field($field, $bug->$name); + } + + return $rh; +} + +sub _user { + my ($self, $user) = @_; + return undef unless $user; + return { + id => _integer($user->id), + login => _string($user->login), + real_name => _string($user->name), + }; +} + +sub _component { + my ($self, $component) = @_; + return { + id => _integer($component->id), + name => _string($component->name), + }; +} + +sub _attachment { + my ($self, $attachment, $is_shallow) = @_; + my $rh = { + id => _integer($attachment->id), + content_type => _string($attachment->contenttype), + creation_time => _time($attachment->attached), + description => _string($attachment->description), + file_name => _string($attachment->filename), + flags => (mapr { $self->_flag($_) } $attachment->flags), + is_obsolete => _boolean($attachment->isobsolete), + is_patch => _boolean($attachment->ispatch), + is_private => _boolean(!is_public($attachment)), + last_change_time => _time($attachment->modification_time), + }; + if (!$is_shallow) { + $rh->{bug} = $self->_bug($attachment->bug); + } + return $rh; +} + +sub _comment { + my ($self, $comment, $is_shallow) = @_; + my $rh = { + id => _integer($comment->bug_id), + body => _string($comment->body), + creation_time => _time($comment->creation_ts), + is_private => _boolean($comment->is_private), + number => _integer($comment->count), + }; + if (!$is_shallow) { + $rh->{bug} = $self->_bug($comment->bug); + } + return $rh; +} + +sub _product { + my ($self, $product) = @_; + return { + id => _integer($product->id), + name => _string($product->name), + }; +} + +sub _flag { + my ($self, $flag) = @_; + my $rh = { + id => _integer($flag->id), + name => _string($flag->type->name), + value => _string($flag->status), + }; + if ($flag->requestee) { + $rh->{'requestee'} = $self->_user($flag->requestee); + } + return $rh; +} + +sub _version { + my ($self, $version) = @_; + return { + id => _integer($version->id), + name => _string($version->name), + }; +} + +sub _milestone { + my ($self, $milestone) = @_; + return undef unless $milestone; + return { + id => _integer($milestone->id), + name => _string($milestone->name), + }; +} + +sub _status { + my ($self, $status) = @_; + return { + id => _integer($status->id), + name => _string($status->name), + }; +} + +1; diff --git a/extensions/Push/lib/Util.pm b/extensions/Push/lib/Util.pm new file mode 100644 index 000000000..f52db6936 --- /dev/null +++ b/extensions/Push/lib/Util.pm @@ -0,0 +1,162 @@ +# 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::Push::Util; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Util qw(datetime_from trim); +use Data::Dumper; +use Encode; +use JSON (); +use Scalar::Util qw(blessed); +use Time::HiRes; + +use base qw(Exporter); +our @EXPORT = qw( + datetime_to_timestamp + debug_dump + get_first_value + hash_undef_to_empty + is_public + mapr + clean_error + change_set_id + canon_email + to_json from_json +); + +# returns true if the specified object is public +sub is_public { + my ($object) = @_; + + my $default_user = Bugzilla::User->new(); + + if ($object->isa('Bugzilla::Bug')) { + return unless $default_user->can_see_bug($object->bug_id); + return 1; + + } elsif ($object->isa('Bugzilla::Comment')) { + return if $object->is_private; + return unless $default_user->can_see_bug($object->bug_id); + return 1; + + } elsif ($object->isa('Bugzilla::Attachment')) { + return if $object->isprivate; + return unless $default_user->can_see_bug($object->bug_id); + return 1; + + } else { + warn "Unsupported class " . blessed($object) . " passed to is_public()\n"; + } + + return 1; +} + +# return the first existing value from the hashref for the given list of keys +sub get_first_value { + my ($rh, @keys) = @_; + foreach my $field (@keys) { + return $rh->{$field} if exists $rh->{$field}; + } + return; +} + +# wrapper for map that works on array references +sub mapr(&$) { + my ($filter, $ra) = @_; + my @result = map(&$filter, @$ra); + return \@result; +} + + +# convert datetime string (from db) to a UTC json friendly datetime +sub datetime_to_timestamp { + my ($datetime_string) = @_; + return '' unless $datetime_string; + return datetime_from($datetime_string, 'UTC')->datetime(); +} + +# replaces all undef values in a hashref with an empty string (deep) +sub hash_undef_to_empty { + my ($rh) = @_; + foreach my $key (keys %$rh) { + my $value = $rh->{$key}; + if (!defined($value)) { + $rh->{$key} = ''; + } elsif (ref($value) eq 'HASH') { + hash_undef_to_empty($value); + } + } +} + +# debugging methods +sub debug_dump { + my ($object) = @_; + local $Data::Dumper::Sortkeys = 1; + my $output = Dumper($object); + $output =~ s/</</g; + print "<pre>$output</pre>"; +} + +# removes stacktrace and "at /some/path ..." from errors +sub clean_error { + my ($error) = @_; + my $path = bz_locations->{'extensionsdir'}; + $error = $1 if $error =~ /^(.+?) at \Q$path/s; + $path = '/loader/0x'; + $error = $1 if $error =~ /^(.+?) at \Q$path/s; + $error =~ s/(^\s+|\s+$)//g; + return $error; +} + +# generate a new change_set id +sub change_set_id { + return "$$." . Time::HiRes::time(); +} + +# remove guff from email addresses +sub clean_email { + my $email = shift; + $email = trim($email); + $email = $1 if $email =~ /^(\S+)/; + $email =~ s/@/@/; + $email = lc $email; + return $email; +} + +# resolve to canonised email form +# eg. glob+bmo@mozilla.com --> glob@mozilla.com +sub canon_email { + my $email = shift; + $email = clean_email($email); + $email =~ s/^([^\+]+)\+[^\@]+(\@.+)$/$1$2/; + return $email; +} + +# json helpers +sub to_json { + my ($object, $pretty) = @_; + if ($pretty) { + return decode('utf8', JSON->new->utf8(1)->pretty(1)->encode($object)); + } else { + return JSON->new->ascii(1)->shrink(1)->encode($object); + } +} + +sub from_json { + my ($json) = @_; + if (utf8::is_utf8($json)) { + $json = encode('utf8', $json); + } + return JSON->new->utf8(1)->decode($json); +} + +1; diff --git a/extensions/Push/template/en/default/hook/admin/admin-end_links_right.html.tmpl b/extensions/Push/template/en/default/hook/admin/admin-end_links_right.html.tmpl new file mode 100644 index 000000000..78e314ab2 --- /dev/null +++ b/extensions/Push/template/en/default/hook/admin/admin-end_links_right.html.tmpl @@ -0,0 +1,18 @@ +[%# 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. + #%] + +[% IF user.in_group('admin') %] + <dt id="push"> + Push + </dt> + <dd> + <a href="page.cgi?id=push_config.html">Configuration</a><br> + <a href="page.cgi?id=push_queues.html">Queues</a><br> + <a href="page.cgi?id=push_log.html">Log</a><br> + </dd> +[% END %] diff --git a/extensions/Push/template/en/default/hook/global/code-error-errors.html.tmpl b/extensions/Push/template/en/default/hook/global/code-error-errors.html.tmpl new file mode 100644 index 000000000..515f00fa8 --- /dev/null +++ b/extensions/Push/template/en/default/hook/global/code-error-errors.html.tmpl @@ -0,0 +1,25 @@ +[%# 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. + #%] + +[% IF error == "push_invalid_payload" %] + [% title = "Invalid payload" %] + An invalid or empty payload was passed to Push. + +[% ELSIF error == "push_invalid_change_set" %] + [% title = "Invalid change_set" %] + An invalid or empty change_set was passed to Push. + +[% ELSIF error == "push_invalid_routing_key" %] + [% title = "Invalid routing_key" %] + An invalid or empty routing_key was passed to Push. + +[% ELSIF error == "push_invalid_connector" %] + [% title = "Invalid connector" %] + An invalid connector was passed to Push. + +[% END %] diff --git a/extensions/Push/template/en/default/hook/global/messages-messages.html.tmpl b/extensions/Push/template/en/default/hook/global/messages-messages.html.tmpl new file mode 100644 index 000000000..e4a016aee --- /dev/null +++ b/extensions/Push/template/en/default/hook/global/messages-messages.html.tmpl @@ -0,0 +1,16 @@ +[%# 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. + #%] + +[% IF message_tag == "push_config_updated" %] + Changes to the configuration have been saved. + Please allow up to 60 seconds for the change to be active. + +[% ELSIF message_tag == "push_message_deleted" %] + The message has been deleted. + +[% END %] diff --git a/extensions/Push/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Push/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..2b8a1c4e0 --- /dev/null +++ b/extensions/Push/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,11 @@ +[%# 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. + #%] + +[% IF error == "push_error" %] + [% error_message FILTER html %] +[% END %] diff --git a/extensions/Push/template/en/default/pages/push_config.html.tmpl b/extensions/Push/template/en/default/pages/push_config.html.tmpl new file mode 100644 index 000000000..6e6507a39 --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_config.html.tmpl @@ -0,0 +1,134 @@ +[%# 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. + #%] + +[% PROCESS global/header.html.tmpl + title = "Push Administration: Configuration" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] + +<script> +var push_defaults = new Array(); +[% FOREACH option = push.config.options %] + [% IF option.name != 'enabled' && option.default != '' %] + push_defaults['global_[% option.name FILTER js %]'] = '[% option.default FILTER js %]'; + [% END %] +[% END %] +[% FOREACH connector = connectors.list %] + [% FOREACH option = connector.config.options %] + [% IF option.name != 'enabled' && option.default != '' %] + push_defaults['[% connector.name FILTER js %]_[% option.name FILTER js %]'] = '[% option.default FILTER js %]'; + [% END %] + [% END %] +[% END %] +</script> + +<form method="POST" action="page.cgi"> +<input type="hidden" name="id" value="push_config.html"> +<input type="hidden" name="save" value="1"> + +<table border="0" cellspacing="0" cellpadding="5" width="100%"> + +[% PROCESS options + name = 'global', + config = push.config +%] + +[% FOREACH connector = connectors.list %] + [% PROCESS options + name = connector.name + config = connector.config + %] +[% END %] + +<tr> + <td> </td> + <td colspan="2"><hr></td> +</tr> + +<tr> + <td> </td> + <td colspan="2"> + <input type="submit" value="Submit Changes"> + <input type="submit" value="Reset to Defaults" onclick="reset_to_defaults(); return false"> + </td> +</tr> + + +<tr> + <td style="min-width: 10em"> </td> + <td> </td> + <td width="100%"> </td> +</tr> + +</table> + +</form> + +[% INCLUDE global/footer.html.tmpl %] + +[% BLOCK options %] + <tr class="connector"> + <th>[% name FILTER ucfirst FILTER html %]</th> + <td colspan="2"><hr></td> + </tr> + [% FOREACH option = config.options %] + [% class = name _ '_tr' IF option.name != 'enabled' %] + <tr class="[% class FILTER html %] option"> + <th> + [% IF option.required %] + <span class="required_option" title="Mandatory option">*</span> + [% END %] + [% option.label FILTER html %] + </th> + <td> + [% IF option.type == 'string' %] + <input type="text" name="[% name FILTER html %].[% option.name FILTER html %]" + value="[% config.${option.name} FILTER html %]" size="60" + id="[% name FILTER html %]_[% option.name FILTER html %]"> + + [% ELSIF option.type == 'password' %] + <input type="password" name="[% name FILTER html %].[% option.name FILTER html %]" + value="[% config.${option.name} FILTER html %]" size="60" + id="[% name FILTER html %]_[% option.name FILTER html %]"> + + [% ELSIF option.type == 'select' %] + <select name="[% name FILTER html %].[% option.name FILTER html %]" + id="[% name FILTER html %]_[% option.name FILTER html %]" + [% IF option.name == 'enabled' && name != 'global' %] + onchange="toggle_options(this.value == 'Enabled', '[% name FILTER js %]')" + [% END %] + > + [% IF option.name != 'enabled' && !option.required %] + <option value=""" + [% ' selected' IF config.${option.name} == "" %]></option> + [% END %] + [% FOREACH value = option.values %] + <option value="[% value FILTER html %]" + [% ' selected' IF config.${option.name} == value %]>[% value FILTER html %]</option> + [% END %] + </select> + + [% ELSE %] + unsupported option type '[% option.type FILTER html %]' + [% END %] + </td> + [% IF option.help %] + <td class="help">[% option.help FILTER html %]</td> + [% ELSE %] + <td> </td> + [% END %] + </tr> + [% END %] + [% IF name != 'global' %] + <script> + var is_enabled = document.getElementById('[% name FILTER js %]_enabled').value == 'Enabled'; + toggle_options(is_enabled, '[% name FILTER js %]'); + </script> + [% END %] +[% END %] diff --git a/extensions/Push/template/en/default/pages/push_log.html.tmpl b/extensions/Push/template/en/default/pages/push_log.html.tmpl new file mode 100644 index 000000000..a51cb22cf --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_log.html.tmpl @@ -0,0 +1,45 @@ +[%# 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. + #%] + +[% PROCESS global/header.html.tmpl + title = "Push Administration: Logs" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] +[% logs = push.log %] + +<table id="report" cellspacing="0"> + +[% IF logs.count %] + <tr class="report-subheader"> + <th nowrap>Connector</th> + <th nowrap>Event Timestamp</th> + <th nowrap>Processed Timestamp</th> + <th nowrap>Status</th> + <th nowrap>Message</th> + </tr> +[% END %] + +[% FOREACH log = logs.list %] + <tr class="row [% loop.count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td nowrap>[% log.connector FILTER html %]</td> + <td nowrap>[% log.push_ts FILTER time FILTER html %]</td> + <td nowrap>[% log.processed_ts FILTER time FILTER html %]</td> + <td nowrap>[% log.result_string FILTER html %]</td> + <td>[% log.data FILTER html %]</td> + </tr> +[% END %] + +<tr> + <td colspan="5"> </td> +</tr> + +</table> + +[% INCLUDE global/footer.html.tmpl %] + diff --git a/extensions/Push/template/en/default/pages/push_queues.html.tmpl b/extensions/Push/template/en/default/pages/push_queues.html.tmpl new file mode 100644 index 000000000..67f079f92 --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_queues.html.tmpl @@ -0,0 +1,102 @@ +[%# 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. + #%] + +[% PROCESS global/header.html.tmpl + title = "Push Administration: Queues" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] + +<table id="report" cellspacing="0"> + +[% PROCESS show_queue + queue = push.queue + title = 'Pending' + pending = 1 +%] + +[% FOREACH connector = push.connectors.list %] + [% NEXT UNLESS connector.enabled %] + [% PROCESS show_queue + queue = connector.backlog + title = connector.name _ ' Backlog' + pending = 0 + %] +[% END %] + +</table> + +[% INCLUDE global/footer.html.tmpl %] + +[% BLOCK show_queue %] + [% count = queue.count %] + <tr class="report-header"> + <th colspan="2"> + [% title FILTER html %] Queue ([% count FILTER html %]) + </th> + [% IF queue.backoff && count %] + <th class="rhs" colspan="5"> + Next Attempt: [% queue.backoff.next_attempt_ts FILTER time %] + </th> + [% ELSE %] + <th colspan="5"> </td> + [% END %] + </tr> + + [% IF count %] + <tr class="report-subheader"> + <th nowrap>Timestamp</th> + <th nowrap>Change Set</th> + [% IF pending %] + <th nowrap colspan="4">Routing Key</th> + [% ELSE %] + <th nowrap>Routing Key</th> + <th nowrap>Last Attempt</th> + <th nowrap>Attempts</th> + <th nowrap>Last Error</th> + [% END %] + <th> </th> + </tr> + [% END %] + + [% FOREACH message = queue.list('limit', 10) %] + <tr class="row [% loop.count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td nowrap>[% message.push_ts FILTER html %]</td> + <td nowrap>[% message.change_set FILTER html %]</td> + [% IF pending %] + <td nowrap colspan="4">[% message.routing_key FILTER html %]</td> + [% ELSE %] + <td nowrap>[% message.routing_key FILTER html %]</td> + [% IF message.attempt_ts %] + <td nowrap>[% message.attempt_ts FILTER time %]</td> + <td nowrap>[% message.attempts FILTER html %]</td> + <td width="100%"> + [% IF message.last_error.length > 40 %] + [% last_error = message.last_error.substr(0, 40) _ '...' %] + [% ELSE %] + [% last_error = message.last_error %] + [% END %] + [% last_error FILTER html %]</td> + [% ELSE %] + <td>-</td> + <td>-</td> + <td width="100%">-</td> + [% END %] + [% END %] + <td class="rhs"> + <a href="?id=push_queues_view.html&[% ~%] + message=[% message.id FILTER url_quote %]&[% ~%] + connector=[% queue.connector FILTER url_quote %]">View</a> + </td> + </tr> + [% END %] + + <tr> + <td colspan="7"> </td> + </tr> +[% END %] diff --git a/extensions/Push/template/en/default/pages/push_queues_view.html.tmpl b/extensions/Push/template/en/default/pages/push_queues_view.html.tmpl new file mode 100644 index 000000000..0e8449b0c --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_queues_view.html.tmpl @@ -0,0 +1,80 @@ +[%# 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. + #%] + +[% PROCESS global/header.html.tmpl + title = "Push Administration: Queues: Payload" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] + +[% IF !message_obj %] + <a href="?id=push_queues.html">Return</a> + [% RETURN %] +[% END %] + +<table id="report" cellspacing="0"> + +<tr> + <th class="report-header" nowrap>Connector</th> + <td width="100%">[% message_obj.connector || '-' FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Message ID</th> + <td width="100%">[% message_obj.message_id FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Push Time</th> + <td width="100%">[% message_obj.push_ts FILTER time FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Change Set</th> + <td width="100%">[% message_obj.change_set FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Routing Key</th> + <td width="100%">[% message_obj.routing_key FILTER html %]</td> +</tr> + +[% IF message_obj.attempts %] + <tr> + <th class="report-header" nowrap>Attempts</th> + <td width="100%">[% message_obj.attempts FILTER html %]</td> + </tr> + <tr> + <th class="report-header" nowrap>Last Attempt Time</th> + <td width="100%">[% message_obj.attempt_ts FILTER time FILTER html %]</td> + </tr> + <tr> + <th class="report-header" nowrap>Last Error</th> + <td width="100%"><b>[% message_obj.last_error FILTER html %]</b></td> + </tr> +[% END %] + +<tr> + <td colspan="2"> + [% IF json %] + <pre>[% json FILTER html %]</pre> + [% ELSE %] + <pre>[% message_obj.payload FILTER html %]</pre> + [% END %] + </td> +</tr> + +<tr class="report-header"> + <th colspan="2"> + <a href="?id=push_queues.html">Return</a> | + <a onclick="return confirm('Are you sure you want to delete this message forever (a long time)?')" + href="?id=push_queues_view.html&delete=1 + [%- %]&message=[% message_obj.id FILTER url_quote %] + [%- %]&connector=[% message_obj.connector FILTER url_quote %]">Delete</a> + </th> +</tr> + +</table> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/Push/template/en/default/setup/strings.txt.pl b/extensions/Push/template/en/default/setup/strings.txt.pl new file mode 100644 index 000000000..bb135f5bb --- /dev/null +++ b/extensions/Push/template/en/default/setup/strings.txt.pl @@ -0,0 +1,11 @@ +# 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. + +%strings = ( + feature_push_amqp => 'Push: AMQP Support', + feature_push_stomp => 'Push: STOMP Support', +); diff --git a/extensions/Push/web/admin.css b/extensions/Push/web/admin.css new file mode 100644 index 000000000..c204fa62a --- /dev/null +++ b/extensions/Push/web/admin.css @@ -0,0 +1,71 @@ +/* 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. */ + +.connector th { + text-align: left; + vertical-align: middle !important; +} + +.option th { + text-align: right; + font-weight: normal !important; + vertical-align: middle !important; +} + +.option .help { + font-style: italic; +} + +.hidden { + display: none; +} + +.required_option { + color: red; + cursor: help; +} + +#report { + border: 1px solid #888888; + width: 100%; +} + +#report td, #report th { + padding: 3px 10px 3px 3px; + border: 0px; +} + +#report th { + text-align: left; +} + +.report-header { + background: #cccccc; +} + +.report-subheader { + background: #ffffff; +} + +.report_row_odd { + background-color: #eeeeee; + color: #000000; +} + +.report_row_even { + background-color: #ffffff; + color: #000000; +} + +#report tr.row:hover { + background-color: #ccccff; +} + +.rhs { + text-align: right !important; +} + diff --git a/extensions/Push/web/admin.js b/extensions/Push/web/admin.js new file mode 100644 index 000000000..599bfd742 --- /dev/null +++ b/extensions/Push/web/admin.js @@ -0,0 +1,37 @@ +/* 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. */ + +var Dom = YAHOO.util.Dom; + +function toggle_options(visible, name) { + var rows = Dom.getElementsByClassName(name + '_tr'); + for (var i = 0, l = rows.length; i < l; i++) { + if (visible) { + Dom.removeClass(rows[i], 'hidden'); + } else { + Dom.addClass(rows[i], 'hidden'); + } + } +} + +function reset_to_defaults() { + if (!push_defaults) return; + for (var id in push_defaults) { + var el = Dom.get(id); + if (!el) continue; + if (el.nodeName == 'INPUT') { + el.value = push_defaults[id]; + } else if (el.nodeName == 'SELECT') { + for (var i = 0, l = el.options.length; i < l; i++) { + if (el.options[i].value == push_defaults[id]) { + el.options[i].selected = true; + break; + } + } + } + } +} |