summaryrefslogtreecommitdiffstats
path: root/Bugzilla/Quantum
diff options
context:
space:
mode:
authorDylan William Hardison <dylan@hardison.net>2018-09-19 00:19:03 +0200
committerGitHub <noreply@github.com>2018-09-19 00:19:03 +0200
commitb8b2a943056adbb112474df7bdf766970a56b2dc (patch)
treeac62e6bcd7d795066ce66256a6fdf2d1be101514 /Bugzilla/Quantum
parent2f3ca2822570ae271ca4603b5d3de119b40d9eb8 (diff)
downloadbugzilla-b8b2a943056adbb112474df7bdf766970a56b2dc.tar.gz
bugzilla-b8b2a943056adbb112474df7bdf766970a56b2dc.tar.xz
Bug 1455495 - Replace apache with Mojolicious
Diffstat (limited to 'Bugzilla/Quantum')
-rw-r--r--Bugzilla/Quantum/CGI.pm160
-rw-r--r--Bugzilla/Quantum/Plugin/BasicAuth.pm40
-rw-r--r--Bugzilla/Quantum/Plugin/BlockIP.pm43
-rw-r--r--Bugzilla/Quantum/Plugin/Glue.pm101
-rw-r--r--Bugzilla/Quantum/Plugin/Hostage.pm86
-rw-r--r--Bugzilla/Quantum/SES.pm254
-rw-r--r--Bugzilla/Quantum/Static.pm30
-rw-r--r--Bugzilla/Quantum/Stdout.pm60
8 files changed, 774 insertions, 0 deletions
diff --git a/Bugzilla/Quantum/CGI.pm b/Bugzilla/Quantum/CGI.pm
new file mode 100644
index 000000000..0a74f1ee5
--- /dev/null
+++ b/Bugzilla/Quantum/CGI.pm
@@ -0,0 +1,160 @@
+# 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::Quantum::CGI;
+use Mojo::Base 'Mojolicious::Controller';
+
+use CGI::Compile;
+use Try::Tiny;
+use Taint::Util qw(untaint);
+use Sys::Hostname;
+use Sub::Quote 2.005000;
+use Sub::Name;
+use Socket qw(AF_INET inet_aton);
+use File::Spec::Functions qw(catfile);
+use File::Slurper qw(read_text);
+use English qw(-no_match_vars);
+use Bugzilla::Quantum::Stdout;
+use Bugzilla::Constants qw(bz_locations);
+
+our $C;
+my %SEEN;
+
+sub load_all {
+ my ( $class, $r ) = @_;
+
+ foreach my $file ( glob '*.cgi' ) {
+ my $name = _file_to_method($file);
+ $class->load_one( $name, $file );
+ $r->any("/$file")->to("CGI#$name");
+ }
+}
+
+sub load_one {
+ my ( $class, $name, $file ) = @_;
+ my $package = __PACKAGE__ . "::$name", my $inner_name = "_$name";
+ my $content = read_text( catfile( bz_locations->{cgi_path}, $file ) );
+ $content = "package $package; $content";
+ untaint($content);
+ my %options = (
+ package => $package,
+ file => $file,
+ line => 1,
+ no_defer => 1,
+ );
+ die "Tried to load $file more than once" if $SEEN{$file}++;
+ my $inner = quote_sub $inner_name, $content, {}, \%options;
+ my $wrapper = sub {
+ my ($c) = @_;
+ my $stdin = $c->_STDIN;
+ local $C = $c;
+ local %ENV = $c->_ENV($file);
+ local $CGI::Compile::USE_REAL_EXIT = 0;
+ local $PROGRAM_NAME = $file;
+ local *STDIN; ## no critic (local)
+ open STDIN, '<', $stdin->path or die "STDIN @{[$stdin->path]}: $!" if -s $stdin->path;
+ tie *STDOUT, 'Bugzilla::Quantum::Stdout', controller => $c; ## no critic (tie)
+ try {
+ Bugzilla->init_page();
+ $inner->();
+ }
+ catch {
+ die $_ unless ref $_ eq 'ARRAY' && $_->[0] eq "EXIT\n";
+ }
+ finally {
+ untie *STDOUT;
+ $c->finish;
+ Bugzilla->cleanup;
+ CGI::initialize_globals();
+ };
+ };
+
+ no strict 'refs'; ## no critic (strict)
+ *{$name} = subname( $name, $wrapper );
+ return 1;
+}
+
+
+sub _ENV {
+ my ( $c, $script_name ) = @_;
+ my $tx = $c->tx;
+ my $req = $tx->req;
+ my $headers = $req->headers;
+ my $content_length = $req->content->is_multipart ? $req->body_size : $headers->content_length;
+ my %env_headers = ( HTTP_COOKIE => '', HTTP_REFERER => '' );
+
+ for my $name ( @{ $headers->names } ) {
+ my $key = uc "http_$name";
+ $key =~ s/\W/_/g;
+ $env_headers{$key} = $headers->header($name);
+ }
+
+ my $remote_user;
+ if ( my $userinfo = $req->url->to_abs->userinfo ) {
+ $remote_user = $userinfo =~ /([^:]+)/ ? $1 : '';
+ }
+ elsif ( my $authenticate = $headers->authorization ) {
+ $remote_user = $authenticate =~ /Basic\s+(.*)/ ? b64_decode $1 : '';
+ $remote_user = $remote_user =~ /([^:]+)/ ? $1 : '';
+ }
+ my $path_info = $c->stash->{'mojo.captures'}{'PATH_INFO'};
+ my %captures = %{ $c->stash->{'mojo.captures'} // {} };
+ foreach my $key ( keys %captures ) {
+ if ( $key eq 'controller' || $key eq 'action' || $key eq 'PATH_INFO' || $key =~ /^REWRITE_/ ) {
+ delete $captures{$key};
+ }
+ }
+ my $cgi_query = Mojo::Parameters->new(%captures);
+ $cgi_query->append( $req->url->query );
+ my $prefix = $c->stash->{bmo_prefix} ? '/bmo/' : '/';
+
+ return (
+ %ENV,
+ CONTENT_LENGTH => $content_length || 0,
+ CONTENT_TYPE => $headers->content_type || '',
+ GATEWAY_INTERFACE => 'CGI/1.1',
+ HTTPS => $req->is_secure ? 'on' : 'off',
+ %env_headers,
+ QUERY_STRING => $cgi_query->to_string,
+ PATH_INFO => $path_info ? "/$path_info" : '',
+ REMOTE_ADDR => $tx->original_remote_address,
+ REMOTE_HOST => $tx->original_remote_address,
+ REMOTE_PORT => $tx->remote_port,
+ REMOTE_USER => $remote_user || '',
+ REQUEST_METHOD => $req->method,
+ SCRIPT_NAME => "$prefix$script_name",
+ SERVER_NAME => hostname,
+ SERVER_PORT => $tx->local_port,
+ SERVER_PROTOCOL => $req->is_secure ? 'HTTPS' : 'HTTP', # TODO: Version is missing
+ SERVER_SOFTWARE => __PACKAGE__,
+ );
+}
+
+sub _STDIN {
+ my $c = shift;
+ my $stdin;
+
+ if ( $c->req->content->is_multipart ) {
+ $stdin = Mojo::Asset::File->new;
+ $stdin->add_chunk( $c->req->build_body );
+ }
+ else {
+ $stdin = $c->req->content->asset;
+ }
+
+ return $stdin if $stdin->isa('Mojo::Asset::File');
+ return Mojo::Asset::File->new->add_chunk( $stdin->slurp );
+}
+
+sub _file_to_method {
+ my ($name) = @_;
+ $name =~ s/\./_/s;
+ $name =~ s/\W+/_/gs;
+ return $name;
+}
+
+1;
diff --git a/Bugzilla/Quantum/Plugin/BasicAuth.pm b/Bugzilla/Quantum/Plugin/BasicAuth.pm
new file mode 100644
index 000000000..e17273404
--- /dev/null
+++ b/Bugzilla/Quantum/Plugin/BasicAuth.pm
@@ -0,0 +1,40 @@
+# 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::Quantum::Plugin::BasicAuth;
+use 5.10.1;
+use Mojo::Base qw(Mojolicious::Plugin);
+
+use Bugzilla::Logging;
+use Carp;
+
+sub register {
+ my ( $self, $app, $conf ) = @_;
+
+ $app->renderer->add_helper(
+ basic_auth => sub {
+ my ( $c, $realm, $auth_user, $auth_pass ) = @_;
+ my $req = $c->req;
+ my ( $user, $password ) = $req->url->to_abs->userinfo =~ /^([^:]+):(.*)/;
+
+ unless ( $realm && $auth_user && $auth_pass ) {
+ croak 'basic_auth() called with missing parameters.';
+ }
+
+ unless ( $user eq $auth_user && $password eq $auth_pass ) {
+ WARN('username and password do not match');
+ $c->res->headers->www_authenticate("Basic realm=\"$realm\"");
+ $c->res->code(401);
+ $c->rendered;
+ return 0;
+ }
+
+ return 1;
+ }
+ );
+}
+
+1; \ No newline at end of file
diff --git a/Bugzilla/Quantum/Plugin/BlockIP.pm b/Bugzilla/Quantum/Plugin/BlockIP.pm
new file mode 100644
index 000000000..058ecbf64
--- /dev/null
+++ b/Bugzilla/Quantum/Plugin/BlockIP.pm
@@ -0,0 +1,43 @@
+package Bugzilla::Quantum::Plugin::BlockIP;
+use 5.10.1;
+use Mojo::Base 'Mojolicious::Plugin';
+
+use Bugzilla::Memcached;
+
+use constant BLOCK_TIMEOUT => 60 * 60;
+
+my $MEMCACHED = Bugzilla::Memcached->new()->{memcached};
+
+sub register {
+ my ( $self, $app, $conf ) = @_;
+
+ $app->hook( before_routes => \&_before_routes );
+ $app->helper( block_ip => \&_block_ip );
+ $app->helper( unblock_ip => \&_unblock_ip );
+}
+
+sub _block_ip {
+ my ( $class, $ip ) = @_;
+ $MEMCACHED->set( "block_ip:$ip" => 1, BLOCK_TIMEOUT ) if $MEMCACHED;
+}
+
+sub _unblock_ip {
+ my ( $class, $ip ) = @_;
+ $MEMCACHED->delete("block_ip:$ip") if $MEMCACHED;
+}
+
+sub _before_routes {
+ my ($c) = @_;
+ return if $c->stash->{'mojo.static'};
+
+ my $ip = $c->tx->remote_address;
+ if ( $MEMCACHED && $MEMCACHED->get("block_ip:$ip") ) {
+ $c->block_ip($ip);
+ $c->res->code(429);
+ $c->res->message('Too Many Requests');
+ $c->res->body('Too Many Requests');
+ $c->finish;
+ }
+}
+
+1;
diff --git a/Bugzilla/Quantum/Plugin/Glue.pm b/Bugzilla/Quantum/Plugin/Glue.pm
new file mode 100644
index 000000000..ded4daf15
--- /dev/null
+++ b/Bugzilla/Quantum/Plugin/Glue.pm
@@ -0,0 +1,101 @@
+# 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::Quantum::Plugin::Glue;
+use 5.10.1;
+use Mojo::Base 'Mojolicious::Plugin';
+
+use Try::Tiny;
+use Bugzilla::Constants;
+use Bugzilla::Logging;
+use Bugzilla::RNG ();
+use JSON::MaybeXS qw(decode_json);
+
+sub register {
+ my ( $self, $app, $conf ) = @_;
+
+ my %D;
+ if ( $ENV{BUGZILLA_HTTPD_ARGS} ) {
+ my $args = decode_json( $ENV{BUGZILLA_HTTPD_ARGS} );
+ foreach my $arg (@$args) {
+ if ( $arg =~ /^-D(\w+)$/ ) {
+ $D{$1} = 1;
+ }
+ else {
+ die "Unknown httpd arg: $arg";
+ }
+ }
+ }
+
+ # hypnotoad is weird and doesn't look for MOJO_LISTEN itself.
+ $app->config(
+ hypnotoad => {
+ proxy => 1,
+ listen => [ $ENV{MOJO_LISTEN} ],
+ },
+ );
+
+ # Make sure each httpd child receives a different random seed (bug 476622).
+ # Bugzilla::RNG has one srand that needs to be called for
+ # every process, and Perl has another. (Various Perl modules still use
+ # the built-in rand(), even though we never use it in Bugzilla itself,
+ # so we need to srand() both of them.)
+ # Also, ping the dbh to force a reconnection.
+ Mojo::IOLoop->next_tick(
+ sub {
+ Bugzilla::RNG::srand();
+ srand();
+ try { Bugzilla->dbh->ping };
+ }
+ );
+
+ $app->hook(
+ before_dispatch => sub {
+ my ($c) = @_;
+ if ( $D{HTTPD_IN_SUBDIR} ) {
+ my $path = $c->req->url->path;
+ if ( $path =~ s{^/bmo}{}s ) {
+ $c->stash->{bmo_prefix} = 1;
+ $c->req->url->path($path);
+ }
+ }
+ Log::Log4perl::MDC->put( request_id => $c->req->request_id );
+ }
+ );
+
+
+ $app->secrets( [ Bugzilla->localconfig->{side_wide_secret} ] );
+
+ $app->renderer->add_handler(
+ 'bugzilla' => sub {
+ my ( $renderer, $c, $output, $options ) = @_;
+ my $vars = delete $c->stash->{vars};
+
+ # Helpers
+ my %helper;
+ foreach my $method ( grep {m/^\w+\z/} keys %{ $renderer->helpers } ) {
+ my $sub = $renderer->helpers->{$method};
+ $helper{$method} = sub { $c->$sub(@_) };
+ }
+ $vars->{helper} = \%helper;
+
+ # The controller
+ $vars->{c} = $c;
+ my $name = $options->{template};
+ unless ( $name =~ /\./ ) {
+ $name = sprintf '%s.%s.tmpl', $options->{template}, $options->{format};
+ }
+ my $template = Bugzilla->template;
+ $template->process( $name, $vars, $output )
+ or die $template->error;
+ }
+ );
+
+ $app->log( MojoX::Log::Log4perl::Tiny->new( logger => Log::Log4perl->get_logger( ref $app ) ) );
+}
+
+1;
diff --git a/Bugzilla/Quantum/Plugin/Hostage.pm b/Bugzilla/Quantum/Plugin/Hostage.pm
new file mode 100644
index 000000000..cbde7b5ee
--- /dev/null
+++ b/Bugzilla/Quantum/Plugin/Hostage.pm
@@ -0,0 +1,86 @@
+package Bugzilla::Quantum::Plugin::Hostage;
+use 5.10.1;
+use Mojo::Base 'Mojolicious::Plugin';
+use Bugzilla::Logging;
+
+sub _attachment_root {
+ my ($base) = @_;
+ return undef unless $base;
+ return $base =~ m{^https?://(?:bug)?\%bugid\%\.([a-zA-Z\.-]+)}
+ ? $1
+ : undef;
+}
+
+sub _attachment_host_regex {
+ my ($base) = @_;
+ return undef unless $base;
+ my $val = $base;
+ $val =~ s{^https?://}{}s;
+ $val =~ s{/$}{}s;
+ my $regex = quotemeta $val;
+ $regex =~ s/\\\%bugid\\\%/\\d+/g;
+ return qr/^$regex$/s;
+}
+
+sub register {
+ my ( $self, $app, $conf ) = @_;
+
+ $app->hook( before_routes => \&_before_routes );
+}
+
+sub _before_routes {
+ my ($c) = @_;
+ state $urlbase = Bugzilla->localconfig->{urlbase};
+ state $urlbase_uri = URI->new($urlbase);
+ state $urlbase_host = $urlbase_uri->host;
+ state $urlbase_host_regex = qr/^bug(\d+)\.\Q$urlbase_host\E$/;
+ state $attachment_base = Bugzilla->localconfig->{attachment_base};
+ state $attachment_root = _attachment_root($attachment_base);
+ state $attachment_host_regex = _attachment_host_regex($attachment_base);
+
+ my $stash = $c->stash;
+ my $req = $c->req;
+ my $url = $req->url->to_abs;
+
+ return if $stash->{'mojo.static'};
+
+ my $hostname = $url->host;
+ return if $hostname eq $urlbase_host;
+
+ my $path = $url->path;
+ return if $path eq '/__lbheartbeat__';
+
+ if ( $attachment_base && $hostname eq $attachment_root ) {
+ DEBUG("redirecting to $urlbase because $hostname is $attachment_root");
+ $c->redirect_to($urlbase);
+ return;
+ }
+ elsif ( $attachment_base && $hostname =~ $attachment_host_regex ) {
+ if ( $path =~ m{^/attachment\.cgi}s ) {
+ return;
+ }
+ else {
+ my $new_uri = $url->clone;
+ $new_uri->scheme( $urlbase_uri->scheme );
+ $new_uri->host($urlbase_host);
+ DEBUG("redirecting to $new_uri because $hostname matches attachment regex");
+ $c->redirect_to($new_uri);
+ return;
+ }
+ }
+ elsif ( my ($id) = $hostname =~ $urlbase_host_regex ) {
+ my $new_uri = $urlbase_uri->clone;
+ $new_uri->path('/show_bug.cgi');
+ $new_uri->query_form( id => $id );
+ DEBUG("redirecting to $new_uri because $hostname includes bug id");
+ $c->redirect_to($new_uri);
+ return;
+ }
+ else {
+ DEBUG("redirecting to $urlbase because $hostname doesn't make sense");
+ $c->redirect_to($urlbase);
+ return;
+ }
+}
+
+1;
diff --git a/Bugzilla/Quantum/SES.pm b/Bugzilla/Quantum/SES.pm
new file mode 100644
index 000000000..03916075d
--- /dev/null
+++ b/Bugzilla/Quantum/SES.pm
@@ -0,0 +1,254 @@
+package Bugzilla::Quantum::SES;
+# 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 5.10.1;
+use Mojo::Base qw( Mojolicious::Controller );
+
+use Bugzilla::Constants qw(ERROR_MODE_DIE);
+use Bugzilla::Logging;
+use Bugzilla::Mailer qw(MessageToMTA);
+use Bugzilla::User ();
+use Bugzilla::Util qw(html_quote remote_ip);
+use JSON::MaybeXS qw(decode_json);
+use LWP::UserAgent ();
+use Try::Tiny qw(catch try);
+
+use Types::Standard qw( :all );
+use Type::Utils;
+use Type::Params qw( compile );
+
+my $Invocant = class_type { class => __PACKAGE__ };
+
+sub main {
+ my ($self) = @_;
+ try {
+ $self->_main;
+ }
+ catch {
+ FATAL("Error in SES Handler: ", $_);
+ $self->_respond( 400 => 'Bad Request' );
+ };
+}
+
+sub _main {
+ my ($self) = @_;
+ Bugzilla->error_mode(ERROR_MODE_DIE);
+ my $message = $self->_decode_json_wrapper( $self->req->body ) // return;
+ my $message_type = $self->req->headers->header('X-Amz-SNS-Message-Type') // '(missing)';
+
+ if ( $message_type eq 'SubscriptionConfirmation' ) {
+ $self->_confirm_subscription($message);
+ }
+
+ elsif ( $message_type eq 'Notification' ) {
+ my $notification = $self->_decode_json_wrapper( $message->{Message} ) // return;
+ unless (
+ # https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html
+ $self->_handle_notification( $notification, 'eventType' )
+
+ # https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html
+ || $self->_handle_notification( $notification, 'notificationType' )
+ )
+ {
+ WARN('Failed to find notification type');
+ $self->_respond( 400 => 'Bad Request' );
+ }
+ }
+
+ else {
+ WARN("Unsupported message-type: $message_type");
+ $self->_respond( 200 => 'OK' );
+ }
+}
+
+sub _confirm_subscription {
+ state $check = compile($Invocant, Dict[SubscribeURL => Str, slurpy Any]);
+ my ($self, $message) = $check->(@_);
+
+ my $subscribe_url = $message->{SubscribeURL};
+ if ( !$subscribe_url ) {
+ WARN('Bad SubscriptionConfirmation request: missing SubscribeURL');
+ $self->_respond( 400 => 'Bad Request' );
+ return;
+ }
+
+ my $ua = ua();
+ my $res = $ua->get( $message->{SubscribeURL} );
+ if ( !$res->is_success ) {
+ WARN( 'Bad response from SubscribeURL: ' . $res->status_line );
+ $self->_respond( 400 => 'Bad Request' );
+ return;
+ }
+
+ $self->_respond( 200 => 'OK' );
+}
+
+my $NotificationType = Enum [qw( Bounce Complaint )];
+my $TypeField = Enum [qw(eventType notificationType)];
+my $Notification = Dict [
+ eventType => Optional [$NotificationType],
+ notificationType => Optional [$NotificationType],
+ slurpy Any,
+];
+
+sub _handle_notification {
+ state $check = compile($Invocant, $Notification, $TypeField );
+ my ( $self, $notification, $type_field ) = $check->(@_);
+
+ if ( !exists $notification->{$type_field} ) {
+ return 0;
+ }
+ my $type = $notification->{$type_field};
+
+ if ( $type eq 'Bounce' ) {
+ $self->_process_bounce($notification);
+ }
+ elsif ( $type eq 'Complaint' ) {
+ $self->_process_complaint($notification);
+ }
+ else {
+ WARN("Unsupported notification-type: $type");
+ $self->_respond( 200 => 'OK' );
+ }
+ return 1;
+}
+
+my $BouncedRecipients = ArrayRef[
+ Dict[
+ emailAddress => Str,
+ action => Str,
+ diagnosticCode => Str,
+ slurpy Any,
+ ],
+];
+my $BounceNotification = Dict [
+ bounce => Dict [
+ bouncedRecipients => $BouncedRecipients,
+ reportingMTA => Str,
+ bounceSubType => Str,
+ bounceType => Str,
+ slurpy Any,
+ ],
+ slurpy Any,
+];
+
+sub _process_bounce {
+ state $check = compile($Invocant, $BounceNotification);
+ my ($self, $notification) = $check->(@_);
+
+ # disable each account that is bouncing
+ foreach my $recipient ( @{ $notification->{bounce}->{bouncedRecipients} } ) {
+ my $address = $recipient->{emailAddress};
+ my $reason = sprintf '(%s) %s', $recipient->{action} // 'error', $recipient->{diagnosticCode} // 'unknown';
+
+ my $user = Bugzilla::User->new( { name => $address, cache => 1 } );
+ if ($user) {
+
+ # never auto-disable admin accounts
+ if ( $user->in_group('admin') ) {
+ Bugzilla->audit("ignoring bounce for admin <$address>: $reason");
+ }
+
+ else {
+ my $template = Bugzilla->template_inner();
+ my $vars = {
+ mta => $notification->{bounce}->{reportingMTA} // 'unknown',
+ reason => $reason,
+ };
+ my $disable_text;
+ $template->process( 'admin/users/bounce-disabled.txt.tmpl', $vars, \$disable_text )
+ || die $template->error();
+
+ $user->set_disabledtext($disable_text);
+ $user->set_disable_mail(1);
+ $user->update();
+ Bugzilla->audit( "bounce for <$address> disabled userid-" . $user->id . ": $reason" );
+ }
+ }
+
+ else {
+ Bugzilla->audit("bounce for <$address> has no user: $reason");
+ }
+ }
+
+ $self->_respond( 200 => 'OK' );
+}
+
+my $ComplainedRecipients = ArrayRef[Dict[ emailAddress => Str, slurpy Any ]];
+my $ComplaintNotification = Dict[
+ complaint => Dict [
+ complainedRecipients => $ComplainedRecipients,
+ complaintFeedbackType => Str,
+ slurpy Any,
+ ],
+ slurpy Any,
+];
+
+sub _process_complaint {
+ state $check = compile($Invocant, $ComplaintNotification);
+ my ($self, $notification) = $check->(@_);
+ my $template = Bugzilla->template_inner();
+ my $json = JSON::MaybeXS->new(
+ pretty => 1,
+ utf8 => 1,
+ canonical => 1,
+ );
+
+ foreach my $recipient ( @{ $notification->{complaint}->{complainedRecipients} } ) {
+ my $reason = $notification->{complaint}->{complaintFeedbackType} // 'unknown';
+ my $address = $recipient->{emailAddress};
+ Bugzilla->audit("complaint for <$address> for '$reason'");
+ my $vars = {
+ email => $address,
+ user => Bugzilla::User->new( { name => $address, cache => 1 } ),
+ reason => $reason,
+ notification => $json->encode($notification),
+ };
+ my $message;
+ $template->process( 'email/ses-complaint.txt.tmpl', $vars, \$message )
+ || die $template->error();
+ MessageToMTA($message);
+ }
+
+ $self->_respond( 200 => 'OK' );
+}
+
+sub _respond {
+ my ( $self, $code, $message ) = @_;
+ $self->render(text => "$message\n", status => $code);
+}
+
+sub _decode_json_wrapper {
+ state $check = compile($Invocant, Str);
+ my ($self, $json) = $check->(@_);
+ my $result;
+ my $ok = try {
+ $result = decode_json($json);
+ }
+ catch {
+ WARN( 'Malformed JSON from ' . $self->tx->remote_address );
+ $self->_respond( 400 => 'Bad Request' );
+ return undef;
+ };
+ return $ok ? $result : undef;
+}
+
+sub ua {
+ my $ua = LWP::UserAgent->new();
+ $ua->timeout(10);
+ $ua->protocols_allowed( [ 'http', 'https' ] );
+ if ( my $proxy_url = Bugzilla->params->{'proxy_url'} ) {
+ $ua->proxy( [ 'http', 'https' ], $proxy_url );
+ }
+ else {
+ $ua->env_proxy;
+ }
+ return $ua;
+}
+
+1;
diff --git a/Bugzilla/Quantum/Static.pm b/Bugzilla/Quantum/Static.pm
new file mode 100644
index 000000000..4543d1b84
--- /dev/null
+++ b/Bugzilla/Quantum/Static.pm
@@ -0,0 +1,30 @@
+# 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::Quantum::Static;
+use Mojo::Base 'Mojolicious::Static';
+use Bugzilla::Constants qw(bz_locations);
+
+my $LEGACY_RE = qr{
+ ^ (?:static/v[0-9]+\.[0-9]+/) ?
+ ( (?:extensions/[^/]+/web|(?:image|skin|j|graph)s)/.+)
+ $
+}xs;
+
+sub file {
+ my ( $self, $rel ) = @_;
+
+ if ( my ($legacy_rel) = $rel =~ $LEGACY_RE ) {
+ local $self->{paths} = [ bz_locations->{cgi_path} ];
+ return $self->SUPER::file($legacy_rel);
+ }
+ else {
+ return $self->SUPER::file($rel);
+ }
+}
+
+1;
diff --git a/Bugzilla/Quantum/Stdout.pm b/Bugzilla/Quantum/Stdout.pm
new file mode 100644
index 000000000..9cf19992c
--- /dev/null
+++ b/Bugzilla/Quantum/Stdout.pm
@@ -0,0 +1,60 @@
+# 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::Quantum::Stdout;
+use 5.10.1;
+use Moo;
+
+use Bugzilla::Logging;
+use Encode;
+use English qw(-no_match_vars);
+
+has 'controller' => (
+ is => 'ro',
+ required => 1,
+);
+
+has '_encoding' => (
+ is => 'rw',
+ default => '',
+);
+
+sub TIEHANDLE { ## no critic (unpack)
+ my $class = shift;
+
+ return $class->new(@_);
+}
+
+sub PRINTF { ## no critic (unpack)
+ my $self = shift;
+ $self->PRINT( sprintf @_ );
+}
+
+sub PRINT { ## no critic (unpack)
+ my $self = shift;
+ my $c = $self->controller;
+ my $bytes = join '', @_;
+ return unless $bytes;
+ if ( $self->_encoding ) {
+ $bytes = encode( $self->_encoding, $bytes );
+ }
+ $c->write($bytes . ( $OUTPUT_RECORD_SEPARATOR // '' ) );
+}
+
+sub BINMODE {
+ my ( $self, $mode ) = @_;
+ if ($mode) {
+ if ( $mode eq ':bytes' or $mode eq ':raw' ) {
+ $self->_encoding('');
+ }
+ elsif ( $mode eq ':utf8' ) {
+ $self->_encoding('utf8');
+ }
+ }
+}
+
+1;