summaryrefslogtreecommitdiffstats
path: root/extensions/Push
diff options
context:
space:
mode:
Diffstat (limited to 'extensions/Push')
-rw-r--r--extensions/Push/Config.pm61
-rw-r--r--extensions/Push/Extension.pm637
-rwxr-xr-xextensions/Push/bin/bugzilla-pushd.pl54
-rw-r--r--extensions/Push/lib/Admin.pm121
-rw-r--r--extensions/Push/lib/BacklogMessage.pm145
-rw-r--r--extensions/Push/lib/BacklogQueue.pm127
-rw-r--r--extensions/Push/lib/Backoff.pm105
-rw-r--r--extensions/Push/lib/Config.pm215
-rw-r--r--extensions/Push/lib/Connector.disabled/ServiceNow.pm434
-rw-r--r--extensions/Push/lib/Connector/AMQP.pm230
-rw-r--r--extensions/Push/lib/Connector/Base.pm106
-rw-r--r--extensions/Push/lib/Connector/File.pm68
-rw-r--r--extensions/Push/lib/Connector/TCL.pm241
-rw-r--r--extensions/Push/lib/Connectors.pm115
-rw-r--r--extensions/Push/lib/Constants.pm41
-rw-r--r--extensions/Push/lib/Daemon.pm96
-rw-r--r--extensions/Push/lib/Log.pm45
-rw-r--r--extensions/Push/lib/LogEntry.pm66
-rw-r--r--extensions/Push/lib/Logger.pm70
-rw-r--r--extensions/Push/lib/Message.pm99
-rw-r--r--extensions/Push/lib/Option.pm66
-rw-r--r--extensions/Push/lib/Push.pm249
-rw-r--r--extensions/Push/lib/Queue.pm72
-rw-r--r--extensions/Push/lib/Serialise.pm318
-rw-r--r--extensions/Push/lib/Util.pm162
-rw-r--r--extensions/Push/template/en/default/hook/admin/admin-end_links_right.html.tmpl18
-rw-r--r--extensions/Push/template/en/default/hook/global/code-error-errors.html.tmpl25
-rw-r--r--extensions/Push/template/en/default/hook/global/messages-messages.html.tmpl16
-rw-r--r--extensions/Push/template/en/default/hook/global/user-error-errors.html.tmpl11
-rw-r--r--extensions/Push/template/en/default/pages/push_config.html.tmpl134
-rw-r--r--extensions/Push/template/en/default/pages/push_log.html.tmpl45
-rw-r--r--extensions/Push/template/en/default/pages/push_queues.html.tmpl102
-rw-r--r--extensions/Push/template/en/default/pages/push_queues_view.html.tmpl80
-rw-r--r--extensions/Push/template/en/default/setup/strings.txt.pl11
-rw-r--r--extensions/Push/web/admin.css71
-rw-r--r--extensions/Push/web/admin.js37
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/</&lt;/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/&#64;/@/;
+ $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>&nbsp;</td>
+ <td colspan="2"><hr></td>
+</tr>
+
+<tr>
+ <td>&nbsp;</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">&nbsp;</td>
+ <td>&nbsp;</td>
+ <td width="100%">&nbsp;</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>&nbsp;
+ [% 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>&nbsp;</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">&nbsp;</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">&nbsp;</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>&nbsp;</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&amp;[% ~%]
+ message=[% message.id FILTER url_quote %]&amp;[% ~%]
+ connector=[% queue.connector FILTER url_quote %]">View</a>
+ </td>
+ </tr>
+ [% END %]
+
+ <tr>
+ <td colspan="7">&nbsp;</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&amp;delete=1
+ [%- %]&amp;message=[% message_obj.id FILTER url_quote %]
+ [%- %]&amp;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;
+ }
+ }
+ }
+ }
+}