Mojolicious-Plugin-WebPush
view release on metacpan or search on metacpan
lib/Mojolicious/Plugin/WebPush.pm view on Meta::CPAN
package Mojolicious::Plugin::WebPush;
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::JSON qw(decode_json encode_json);
use Mojo::URL;
use Crypt::PK::ECC;
use MIME::Base64 qw(encode_base64url decode_base64url);
use Crypt::JWT qw(encode_jwt decode_jwt);
use Crypt::RFC8188 qw(ece_encrypt_aes128gcm);
our $VERSION = '0.05';
my @MANDATORY_CONF = qw(
subs_session2user_p
save_endpoint
subs_create_p
subs_read_p
subs_delete_p
);
my @AUTH_CONF = qw(claim_sub ecc_private_key);
my $DEFAULT_PUSH_HANDLER = <<'EOF';
event => {
var msg = event.data.json();
var title = msg.title;
delete msg.title;
event.waitUntil(self.registration.showNotification(title, msg));
}
EOF
sub _decode {
my ($bytes) = @_;
my $body = eval { decode_json($bytes) };
# conceal error info like versions from attackers
return (0, "Malformed request") if $@;
(1, $body);
}
sub _error {
my ($c, $error) = @_;
$c->render(status => 500, json => { errors => [ { message => $error } ] });
}
sub _make_route_handler {
my ($subs_session2user_p, $subs_create_p) = @_;
sub {
my ($c) = @_;
my ($decode_ok, $body) = _decode($c->req->body);
return _error($c, $body) if !$decode_ok;
eval { validate_subs_info($body) };
return _error($c, $@) if $@;
return $subs_session2user_p->($c, $c->session)->then(
sub { $subs_create_p->($c, $_[0], $body) },
)->then(
sub { $c->render(json => { data => { success => \1 } }) },
sub { _error($c, @_) },
);
};
}
sub _make_auth_helper {
my ($app, $conf) = @_;
my $exp_offset = $conf->{claim_exp_offset} || 86400;
my $key = Crypt::PK::ECC->new($conf->{ecc_private_key});
my $claims_start = { sub => $conf->{claim_sub} };
my $pkey = encode_base64url $key->export_key_raw('public');
$app->helper('webpush.public_key' => sub { $pkey });
sub {
my ($c, $subs_info) = @_;
my $aud = Mojo::URL->new($subs_info->{endpoint})->path(Mojo::Path->new->trailing_slash(0)).'';
my $claims = { aud => $aud, exp => time + $exp_offset, %$claims_start };
my $token = encode_jwt key => $key, alg => 'ES256', payload => $claims;
"vapid t=$token,k=$pkey";
};
}
sub _verify_helper {
my ($app, $auth_header_value) = @_;
(my $schema, $auth_header_value) = split ' ', $auth_header_value;
return if $schema ne 'vapid';
my %k2v = map split('=', $_), split ',', $auth_header_value;
eval {
my $key = Crypt::PK::ECC->new;
$key->import_key_raw(decode_base64url($k2v{k}), 'P-256');
decode_jwt token => $k2v{t}, key => $key, alg => 'ES256', verify_exp => 0;
};
}
sub _encrypt_helper {
my ($c, $plaintext, $receiver_key, $auth_key) = @_;
die "Invalid p256dh key specified\n"
if length($receiver_key) != 65 or $receiver_key !~ /^\x04/;
my $onetime_key = Crypt::PK::ECC->new->generate_key('prime256v1');
ece_encrypt_aes128gcm(
$plaintext, (undef) x 2, $onetime_key, $receiver_key, $auth_key,
);
}
sub _send_helper {
my ($c, $message, $user_id, $ttl, $urgency) = @_;
$ttl ||= 30;
$urgency ||= 'normal';
$c->webpush->read_p($user_id)->then(sub {
my ($subs_info) = @_;
my $body = $c->webpush->encrypt(
encode_json($message),
map decode_base64url($_), @{$subs_info->{keys}}{qw(p256dh auth)}
);
my $headers = {
Authorization => $c->webpush->authorization($subs_info),
'Content-Length' => length($body),
'Content-Encoding' => 'aes128gcm',
TTL => $ttl,
Urgency => $urgency,
};
$c->app->ua->post_p($subs_info->{endpoint}, $headers, $body);
})->then(sub {
my ($tx) = @_;
return $c->webpush->delete_p($user_id)->then(sub {
{ data => { success => \1 } }
}) if $tx->res->code == 404 or $tx->res->code == 410;
return { errors => [ { message => $tx->res->body } ] }
if $tx->res->code > 399;
{ data => { success => \1 } };
}, sub {
{ errors => [ { message => $_[0] } ] }
});
}
sub register {
my ($self, $app, $conf) = @_;
my @config_errors = grep !exists $conf->{$_}, @MANDATORY_CONF;
die "Missing config keys @config_errors\n" if @config_errors;
$app->helper('webpush.create_p' => sub {
eval { validate_subs_info($_[2]) };
return Mojo::Promise->reject($@) if $@;
goto &{ $conf->{subs_create_p} };
});
$app->helper('webpush.read_p' => $conf->{subs_read_p});
$app->helper('webpush.delete_p' => $conf->{subs_delete_p});
$app->helper('webpush.authorization' => (grep !$conf->{$_}, @AUTH_CONF)
? sub { die "Must provide @AUTH_CONF\n" }
: _make_auth_helper($app, $conf)
);
$app->helper('webpush.verify_token' => \&_verify_helper);
$app->helper('webpush.encrypt' => \&_encrypt_helper);
$app->helper('webpush.send_p' => \&_send_helper);
my $r = $app->routes;
$r->post($conf->{save_endpoint} => _make_route_handler(
@$conf{qw(subs_session2user_p subs_create_p)},
), 'webpush.save');
push @{ $app->renderer->classes }, __PACKAGE__;
$app->serviceworker->add_event_listener(
push => $conf->{push_handler} || $DEFAULT_PUSH_HANDLER
);
$self;
}
sub validate_subs_info {
my ($info) = @_;
die "Expected object\n" if ref $info ne 'HASH';
my @errors = map "no $_", grep !exists $info->{$_}, qw(keys endpoint);
push @errors, map "no $_", grep !exists $info->{keys}{$_}, qw(auth p256dh);
die "Errors found in subscription info: " . join(", ", @errors) . "\n"
if @errors;
}
1;
=encoding utf8
=head1 NAME
Mojolicious::Plugin::WebPush - plugin to aid real-time web push
=head1 SYNOPSIS
# Mojolicious::Lite
my $sw = plugin 'ServiceWorker' => { debug => 1 };
my $webpush = plugin 'WebPush' => {
save_endpoint => '/api/savesubs',
subs_session2user_p => \&subs_session2user_p,
subs_create_p => \&subs_create_p,
subs_read_p => \&subs_read_p,
subs_delete_p => \&subs_delete_p,
ecc_private_key => 'vapid_private_key.pem',
claim_sub => "mailto:admin@example.com",
};
sub subs_session2user_p {
my ($c, $session) = @_;
return Mojo::Promise->reject("Session not logged in") if !$session->{user_id};
Mojo::Promise->resolve($session->{user_id});
}
sub subs_create_p {
my ($c, $session, $subs_info) = @_;
app->db->save_subs_p($session->{user_id}, $subs_info);
}
sub subs_read_p {
my ($c, $user_id) = @_;
app->db->lookup_subs_p($user_id);
}
sub subs_delete_p {
my ($c, $user_id) = @_;
app->db->delete_subs_p($user_id);
}
=head1 DESCRIPTION
L<Mojolicious::Plugin::WebPush> is a L<Mojolicious> plugin. In
order to function, your app needs to have first installed
L<Mojolicious::Plugin::ServiceWorker> as shown in the synopsis above.
=head1 METHODS
L<Mojolicious::Plugin::WebPush> inherits all methods from
( run in 0.571 second using v1.01-cache-2.11-cpan-39bf76dae61 )