Skip to content

Instantly share code, notes, and snippets.

@argrath
Created April 21, 2017 11:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save argrath/e9e4e8a41230295df6e5be603f9163ac to your computer and use it in GitHub Desktop.
Save argrath/e9e4e8a41230295df6e5be603f9163ac to your computer and use it in GitHub Desktop.
MojoliciousでMastodonの更新通知を受ける
use strict;
use warnings;
use Mojolicious::Lite;
use Data::Dumper;
use lib 'lib';
# 購読するAtomフィード
my $topic = 'https://friends.nico/users/argrath.atom';
# 使うHubサーバ
my $hub = 'https://friends.nico/api/push';
plugin 'PubSubHubbub';
any('/callback')->pubsub;
# Subscribe to a feed
get '/subscribe' => sub {
my $c = shift;
print "subscribe request\n";
$c->pubsub->subscribe(
topic => $topic,
hub => $hub
);
} => 'index';
# Unsubscribe from a feed
get '/unsubscribe' => sub {
my $c = shift;
print "unsubscribe request\n";
$c->pubsub->unsubscribe(
topic => $topic,
hub => $hub
);
} => 'index';
app->callback(pubsub_accept => sub {
print "pubsub_accept\n";
my ($c, $type, $topics) = @_;
print Dumper($topics);
#フィルタリングしない
return $topics;
});
app->callback(pubsub_verify => sub {
print "pubsub_verify\n";
my ($c, $params) = @_;
print Dumper($params);
return 1;
});
app->hook(
on_pubsub_content => sub {
print "pubsub_content\n";
my ($c, $type, $dom) = @_;
print $dom->content . "\n";
return;
});
app->start;
__DATA__
@@ index.html.ep
<html>
<head>
<title>PuSH</title>
</head>
<body>
</body>
</html>
package Mojolicious::Plugin::PubSubHubbub;
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::UserAgent;
use Mojo::DOM;
use Mojo::ByteStream 'b';
use Mojo::Util qw/secure_compare hmac_sha1_sum/;
our $VERSION = '0.17';
# Todo:
# - Prevent log injection
# - Make everything async (top priority)
# - Maybe allow something like ->feed_to_json (look at superfeedr)
# - Test ->discover
# Default lease seconds before automatic subscription refreshing
has 'lease_seconds' => ( 9 * 24 * 60 * 60 );
has hub => 'http://pubsubhubbub.appspot.com/';
my $FEED_TYPE_RE = qr{^(?i:application/(atom|r(?:ss|df))\+xml)};
my $FEED_ENDING_RE = qr{(?i:\.(r(?:ss|df)|atom))$};
# User Agent Name
my $UA_NAME = __PACKAGE__ . ' v' . $VERSION;
# Prototypes
sub _add_topics;
# Register plugin
sub register {
my ($plugin, $mojo, $param) = @_;
$param ||= {};
# Load parameter from Config file
if (my $config_param = $mojo->config('PubSubHubbub')) {
$param = { %$param, %$config_param };
};
my $helpers = $mojo->renderer->helpers;
# Load 'callback' plugin
unless (exists $helpers->{'callback'}) {
$mojo->plugin('Util::Callback');
};
# Set callbacks on registration
$mojo->callback([qw/pubsub_accept pubsub_verify/] => $param);
# Load 'endpoint' plugin
unless (exists $helpers->{'endpoint'}) {
$mojo->plugin('Util::Endpoint');
};
# Load 'randomstring' plugin
$mojo->plugin('Util::RandomString' => {
pubsub_challenge => {
length => 12,
alphabet => [ 'A' .. 'Z', 'a' .. 'z', 0 .. 9 ]
}
});
# Set hub attribute
if ($param->{hub}) {
$plugin->hub($param->{hub});
};
# Establish an endpoint
$mojo->endpoint('pubsub-hub' => $plugin->hub);
# Set lease_seconds attribute
if ($param->{lease_seconds}) {
$plugin->lease_seconds($param->{lease_seconds});
};
# Add 'pubsub' shortcut
$mojo->routes->add_shortcut(
pubsub => sub {
my ($route, $param) = @_;
# Set param default to 'cb'
$param ||= 'cb';
# 'hub' is currently not supported
return unless $param eq 'cb';
# Set PubSubHubbub endpoints
$route->endpoint('pubsub-callback');
# Add 'callback' route
$route->to(
cb => sub {
my $c = shift;
# Hook on verification
return $plugin->verify($c) if $c->param('hub.mode');
# Hook on callback
return $plugin->callback($c);
});
});
$mojo->helper(
'pubsub.publish' => sub {
$plugin->publish( @_ );
});
# Add 'subscribe' and 'unsubscribe' helper
foreach my $action (qw(subscribe unsubscribe)) {
$mojo->helper(
"pubsub.${action}" => sub {
$plugin->_change_subscription(shift, mode => $action, @_);
});
};
$mojo->helper(
'pubsub.discover' => sub {
$plugin->discover( @_ )
}
);
};
# Ping a hub for topics
sub publish {
my $plugin = shift;
my $c = shift;
# Nothing to publish or no hub defined
return unless @_ || !$plugin->hub;
# Set all urls
my @urls = map($c->endpoint($_), @_);
# Create post message
my %post = (
'hub.mode' => 'publish',
'hub.url' => \@urls
);
# Get user agent
my $ua = Mojo::UserAgent->new(
max_redirects => 3,
name => $UA_NAME
);
my $msg = 'Cannot ping hub';
$msg .= ' - maybe no SSL support' if index($plugin->hub, 'https') == 0;
# Blocking
# Post to hub
my $tx = $ua->post( $plugin->hub => form => \%post );
my $res = $tx->success;
# No response
unless ($res) {
$c->app->log->warn($msg);
return;
};
# is 2xx, incl. 204 aka successful
return 1 if $res->is_success;
# Not successful
return;
};
# Verify a changed subscription or automatically refresh
sub verify {
my $plugin = shift;
my $c = shift;
# Good request
if ($c->param('hub.topic') &&
$c->param('hub.challenge') &&
$c->param('hub.mode') =~ /^(?:un)?subscribe$/) {
my $challenge = $c->param('hub.challenge');
my %param;
foreach (qw/mode
topic
verify
lease_seconds
verify_token/) {
$param{$_} = $c->param("hub.$_") if $c->param("hub.$_");
};
# Get verification callback
my $ok = $c->callback(
pubsub_verify => \%param
);
# Render challenge
return $c->render(
'status' => 200,
'format' => 'text',
'data' => $challenge
) if $ok;
};
# Not found
return $c->reply->not_found;
};
# Discover links from header
# This is extremely simplified from https://tools.ietf.org/html/rfc5988
sub _discover_header_links {
my $header = shift;
my $header_hash = $header->to_hash(1);
my @links = (@{$header_hash->{Link} // []}, @{$header_hash->{link} // []});
my %links;
# Iterate through all header links
foreach (@links) {
# Make multiline headers one line
$_ = join(' ', @$_) if ref $_;
# Check for link with correct relation
if ($_ =~ /^\<([^>]+?)\>(.*?rel\s*=\s*"(self|hub|alternate)".*?)$/mi) {
# Create new link hash
my %link = ( href => $1, rel => $3 );
# There may be more than one reference
my $check = $2;
# Set type
if ($check =~ /type\s*=\s*"([^"]+?)"/omi) {
my $type = $1;
next if $type && $type !~ $FEED_TYPE_RE;
$link{type} = $type;
$link{short_type} = $1;
};
# Set title
if ($check =~ /title\s*=\s*"([^"]+?)"/omi) {
$link{title} = $1;
};
# Check file ending for short type
unless ($link{short_type}) {
$link{short_type} = $1 if $link{href} =~ $FEED_ENDING_RE;
};
# Push found link
my $rel = $link{rel};
$links{$rel} //= [];
push(@{$links{$rel}}, \%link);
};
};
# Return array
return \%links;
};
# Discover links from dom tree
sub _discover_dom_links {
my $dom = shift;
my %links;
# Find alternate representations
$dom->find('link[rel="alternate"], link[rel="self"], link[rel="hub"]')->each(
sub {
my ($href, $rel, $type, $title) = @{$_->attr}{qw/href rel type title/};
# Is no supported type
return if $type && $type !~ $FEED_TYPE_RE;
# Set short type
my $short_type = $1 if $1;
return unless $href && $rel;
# Create new link hash
my %link = ( href => $href, rel => $rel );
# Short type yet not known
unless ($short_type) {
# Set short type by file ending
$link{short_type} = $1 if $href =~ m/\.(r(?:ss|df)|atom)$/i;
}
# Set short type
else {
$link{short_type} = $short_type;
};
# Set title and type
$link{title} = $title if $title;
$link{type} = $type if $type;
# Push found link
$links{$rel} //= [];
push(@{$links{$rel}}, \%link);
}
);
# Return array
return \%links;
};
# Heuristically sort links to best match the topic
sub _discover_sort_links {
my $links = shift;
my ($topic, $hub);
# Get self link as topic
if ($links->{self}) {
# Find best match of all returned links
foreach my $link (@{$links->{self}}) {
$topic ||= $link;
if ($link->{short_type} && !$topic->{short_type}) {
$topic = $link;
};
};
};
# Get hub
if ($links->{hub}) {
# Find best match of all returned links
foreach my $link (@{$links->{hub}}) {
$hub ||= $link;
if ($link->{short_type} && !$hub->{short_type}) {
$hub = $link;
};
};
};
# Already found topic and hub
return ($topic, $hub) if $topic && $hub;
# Check alternates
my $alternate = $links->{alternate};
# Search in alternate representations for best match
if ($alternate) {
# Iterate through all alternate links
# and check their titles
foreach my $link (@$alternate) {
# No title given
unless ($link->{title}) {
$link->{pref} = 2;
}
# Guess which feed is best based on the title
elsif ($link->{title} =~ /(?i:feed|stream)/i) {
# This is more likely a comment feed
if ($link->{title} =~ /[ck]omment/i) {
$link->{pref} = 1;
}
# This may be the correct feed
else {
$link->{pref} = 3;
};
}
# Don't know ...
else {
$link->{pref} = 2;
};
};
# Get best topic
($topic) = (sort {
# Sort by title
if ($a->{pref} < $b->{pref}) {
return 1;
}
elsif ($a->{pref} > $b->{pref}) {
return -1;
}
# Sort by type
elsif ($a->{short_type} gt $b->{short_type}) {
return 1;
}
elsif ($a->{short_type} lt $b->{short_type}) {
return -1;
}
# Sort by length
elsif (length($a->{href}) > length($b->{href})) {
return 1;
}
elsif (length($a->{href}) <= length($b->{href})) {
return -1;
}
# Equal
else {
return -1;
};
} @$alternate);
};
# Maybe empty ... maybe not
return ($topic, $hub);
};
# Discover topic and hub based on a URI
# That's a rather complex heuristic, but should gain good results
sub discover {
my $plugin = shift;
my $c = shift;
# No uri given
return () unless $_[0];
# Get uri
my $base = Mojo::URL->new( shift ) or return ();
# Set base to uri
$base->base($c->req->url);
# Initialize UserAgent
my $ua = Mojo::UserAgent->new(
max_redirects => 3,
name => $UA_NAME
);
# Initialize variables
my ($hub, $topic, $nbase, $ntopic);
# Retrieve resource
my $tx = $ua->get($base);
if ($tx->success) {
# Change base after possible redirects
$base = $tx->req->url;
# Get response
my $res = $tx->res;
# Check sorted header links
($topic, $hub) = _discover_sort_links(
_discover_header_links($res->headers)
);
# Fine
unless ($topic && $hub) {
my $dom = $res->dom;
# Check sorted dom links
($topic, $hub) = _discover_sort_links(
_discover_dom_links($dom)
);
};
# Fine
if ($topic && !$hub) {
# Initialize new UserAgent
$ua = Mojo::UserAgent->new(
max_redirects => 3,
name => $UA_NAME
);
# Set new base base
$nbase = Mojo::URL->new($topic->{href})->base($base)->to_abs;
# Retrieve resource
$tx = $ua->get($nbase);
# Request was successful
if ($tx->success) {
# Change nbase after possible redirects
$nbase = $tx->req->url;
# Get response
$res = $tx->res;
# Check sorted header links
($ntopic, $hub) = _discover_sort_links(
_discover_header_links($res->headers)
);
unless ($ntopic && $hub) {
# Check sorted dom links
($ntopic, $hub) = _discover_sort_links(
_discover_dom_links($res->dom)
);
};
}
# Reset nbase as no connection occurred
else {
$nbase = undef;
};
};
};
# Make relative path for topics and hubs absolute
$hub = Mojo::URL->new($hub->{href})->base( $nbase || $base )->to_abs if $hub;
# New topic is set
if ($ntopic) {
$topic = Mojo::URL->new($ntopic->{href})->base($nbase)->to_abs;
}
# Old topic is set
elsif ($topic) {
$topic = Mojo::URL->new($topic->{href})->base($base)->to_abs;
};
# Return
return ($topic, $hub);
};
# subscribe or unsubscribe from a topic
sub _change_subscription {
my $plugin = shift;
my $c = shift;
my %param = @_;
my $log = $c->app->log;
# Get callback endpoint
# Works only if endpoints provided
unless ($param{callback} ||= $c->endpoint('pubsub-callback')) {
$log->error('You have to specify a callback endpoint') and return;
};
# No topic or hub url given
unless (exists $param{topic} &&
$param{topic} =~ m{^https?://}i &&
exists $param{hub}) {
$log->warn('You have to specify a topic and a hub');
return;
};
my $mode = $param{mode};
# delete lease seconds if no integer
if (exists $param{lease_seconds} &&
($mode eq 'unsubscribe' || $param{lease_seconds} !~ /^\d+$/)
) {
delete $param{lease_seconds};
};
# Set to default
$param{lease_seconds} ||= $plugin->lease_seconds if $mode eq 'subscribe';
# Render post string
my %post = ( callback => $param{callback} );
foreach ( qw/mode topic verify lease_seconds secret/ ) {
$post{ $_ } = $param{ $_ } if exists $param{ $_ } && $param{ $_ };
};
# Use verify token
$post{verify_token} =
exists $param{verify_token} ?
$param{verify_token} :
($param{verify_token} =
$c->random_string('pubsub_challenge'));
$post{verify} = "${_}sync" foreach ('a', '');
my $mojo = $c->app;
$mojo->plugins->emit_hook(
"before_pubsub_$mode" => ($c, \%param, \%post)
);
# Prefix all parameters
%post = map { 'hub.' . $_ => $post{$_} } keys %post;
# Get user agent
my $ua = Mojo::UserAgent->new(
max_redirects => 3,
name => $UA_NAME
);
# Send subscription change to hub
my $tx = $ua->post($param{hub} => form => \%post);
my $res = $tx->success;
# No response
unless ($res) {
my $msg = 'Cannot ping hub';
$msg .= ' - maybe no SSL support' if index($param{hub}, 'https') == 0;
$log->warn($msg);
return;
};
$mojo->plugins->emit_hook(
"after_pubsub_$mode" => (
$c, $param{hub}, \%post, $res->code, $res->body
));
# is 2xx, incl. 204 aka successful and 202 aka accepted
my $success = $res->is_success ? 1 : 0;
return ($success, $res->{body}) if wantarray;
return $success;
};
# Incoming data callback
sub callback {
my $plugin = shift;
my $c = shift;
my $log = $c->app->log;
# fallback to atom
my $ct = $c->req->headers->header('Content-Type') || 'application/atom+xml';
my $type;
# Is Atom
if ($ct =~ m{^application/atom\+xml}) {
$type = 'atom';
}
# Is RSS
elsif ($ct =~ m{^application/r(?:ss|df)\+xml}) {
$type = 'rss';
}
# Unsupported content type
else {
$log->warn("Unsupported media type: $ct") if $c->req->body;
return _render_fail($c);
};
my $dom = Mojo::DOM->new(xml => 1, charset => 'UTF-8');
# Parse fat ping
$dom->parse(b($c->req->body)->decode->to_string);
# Find topics in Payload
my $topics = _find_topics($type, $dom);
# No topics to process - but technically fine
return _render_success($c) unless $topics->[0];
# Save unfiltered topics for later comparison
my @old_topics = @$topics;
# Check for secret and which topics are wanted
($topics, my $secret, my $x_hub_on_behalf_of) =
$c->callback(pubsub_accept => $type, $topics);
$x_hub_on_behalf_of ||= 1;
# No topics to process
# return _render_success( $c => $x_hub_on_behalf_of )
return _render_success( $c => 1 ) unless scalar @$topics;
# Todo: Async with on(finish => ..)
# Secret is needed
if ($secret) {
# Unable to verify secret
unless ( _check_signature( $c, $secret )) {
$log->debug(
'Unable to verify secret for ' . join('; ', @$topics)
);
# return _render_success( $c => $x_hub_on_behalf_of );
return _render_success( $c => 1 );
};
};
# Some topics are unwanted
if (@$topics != @old_topics) {
# filter dom based on topics
$topics = _filter_topics($dom, $topics);
};
$c->app->plugins->emit_hook(
on_pubsub_content => $c, $type, $dom
);
# Successful
return _render_success( $c => $x_hub_on_behalf_of );
};
# Find topics of entries
sub _find_topics {
my $type = shift;
my $dom = shift;
# Get all source links
my $links = $dom->find('source > link[rel="self"][href]');
# Save href as topics
my @topics = @{ $links->map( sub { $_->attr('href') } ) } if $links;
# Find all entries, regardless if rss or atom
my $entries = $dom->find('item, feed > entry');
# Not every entry has a source
if ($links->size != $entries->size) {
# One feed or entry
my $link = $dom->at(
'feed > link[rel="self"][href],' .
'channel > link[rel="self"][href]'
);
my $self_href;
# Channel or feed link
if ($link) {
$self_href = $link->attr('href');
}
# Source of first item in RSS
elsif (!$self_href && $type eq 'rss') {
# Possible
$link = $dom->at('item > source');
$self_href = $link->attr('url') if $link;
};
# Add topic to all entries
_add_topics($type, $dom, $self_href) if $self_href;
# Get all source links
$links = $dom->find('source > link[rel="self"][href]');
# Save href as topics
@topics = @{ $links->map( sub { $_->attr('href') } ) } if $links;
};
# Unify list
if (@topics > 1) {
my %topics = map { $_ => 1 } @topics;
@topics = sort keys %topics;
};
return \@topics;
};
# Add topic to entries
sub _add_topics {
state $atom_ns = 'http://www.w3.org/2005/Atom';
my ($type, $dom, $self_href) = @_;
my $link = qq{<link rel="self" href="$self_href" />};
# Add source information to each entry
$dom->find('item, entry')->each(
sub {
my $entry = shift;
my $source;
# Sources are found
if (my $sources = $entry->find('source')) {
foreach my $s (@$sources) {
$source = $s and last if $s->namespace eq $atom_ns;
};
};
# No source found
unless ($source) {
$source = $entry->append_content(qq{<source xmlns="$atom_ns" />})
->at(qq{source[xmlns="$atom_ns"]});
}
# Link already there
elsif ($source->at('link[rel="self"][href]')) {
return $dom;
};
# Add link
$source->append_content( $link );
});
return $dom;
};
# filter entries based on their topic
sub _filter_topics {
my $dom = shift;
my %allowed = map { $_ => 1 } @{ shift(@_) };
my $links = $dom->find(
'feed > entry > source > link[rel="self"][href],' .
'item > source > link[rel="self"][href]'
);
my %topics;
# Delete entries that are not allowed
$links->each(
sub {
my $l = shift;
my $href = $l->attr('href');
# entry is not allowed
unless (exists $allowed{$href}) {
$l->parent->parent->replace('');
}
# Entry is fine and found
else {
$topics{$href} = 1;
};
});
return [ sort keys %topics ];
};
# Check signature
sub _check_signature {
my ($c, $secret) = @_;
my $req = $c->req;
# Get signature
my $signature = $req->headers->header('X-Hub-Signature');
# Signature expected but not given
return unless $signature;
# Delete signature prefix - don't remind, if it's not there.
$signature =~ s/^sha1=//i;
# Generate check signature
my $signature_check = hmac_sha1_sum $req->body, $secret;
# Return true if signature check succeeds
return secure_compare $signature, $signature_check;
};
# Render success
sub _render_success {
my $c = shift;
my $x_hub_on_behalf_of = shift;
# Set X-Hub-On-Behalf-Of header
if ($x_hub_on_behalf_of &&
$x_hub_on_behalf_of =~ s/^\s*(\d+)\s*$/$1/) {
# Set X-Hub-On-Behalf-Of header
$c->res->headers->header(
'X-Hub-On-Behalf-Of' => $x_hub_on_behalf_of
);
};
# Render success with no content
return $c->render(
status => 204,
format => 'text',
data => ''
);
};
# Render fail
sub _render_fail {
my $c = shift;
my $fail =<<'FAIL';
<!DOCTYPE html>
<html>
<head>
<title>PubSubHubbub Endpoint</title>
</head>
<body>
<h1>PubSubHubbub Endpoint</h1>
<p>
This is an endpoint for the
<a href="http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html">PubSubHubbub protocol</a>
</p>
<p>Your request was not correct.</p>
</body>
</html>
FAIL
return $c->render(
data => $fail,
status => 400 # bad request
);
};
1;
__END__
=pod
=head1 NAME
Mojolicious::Plugin::PubSubHubbub - Publish and Subscribe with PubSubHubbub
=head1 SYNOPSIS
# Mojolicious
$app->plugin(PubSubHubbub => {
hub => 'https://hub.example.org/',
pubsub_verify => sub {
return 1;
}
});
my $r = $app->routes;
$r->route('/:user/callback_url')->pubsub;
# Mojolicious::Lite
plugin 'PubSubHubbub' => {
hub => 'https://hub.example.org'
};
any('/:user/callback_url')->pubsub;
# In Controllers:
# Publish feeds
$c->pubsub->publish(
'https://sojolicio.us/blog.atom',
'https://sojolicio.us/activity.atom'
);
# Subscribe to a feed
$c->pubsub->subscribe(
topic => 'https://sojolicio.us/feed.atom',
hub => 'https://hub.sojolicio.us'
);
# Discover a resource
my ($topic, $hub) = $c->pubsub->discover('http://sojolicio.us/');
if ($topic && $hub) {
$c->pubsub->subscribe( topic => $topic, hub => $hub );
};
# Unsubscribe from a feed
$c->pubsub->unsubscribe(
topic => 'https://sojolicio.us/feed.atom',
hub => 'https://hub.sojolicio.us'
);
=head1 DESCRIPTION
L<Mojolicious::Plugin::PubSubHubbub> is a plugin to publish and subscribe to
L<PubSubHubbub 0.3|http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html>
Webhooks.
The plugin currently supports the publisher and subscriber part of the protocol,
I<not> the hub part.
This plugin is data store agnostic.
Please use this plugin by applying hooks and callbacks.
B<This module is an early release! There may be significant changes in the future.>
=head1 ATTRIBUTES
=head2 hub
$ps->hub('http://pubsubhubbub.appspot.com/');
my $hub = $ps->hub;
The preferred hub. Currently local hubs are not supported.
Establishes an L<endpoint|Mojolicious::Plugin::Util::Endpoint> called C<pubsub-hub>.
Defaults to L<pubsubhubbub.appspot.com|http://pubsubhubbub.appspot.com/>,
but this may change without notification.
=head2 lease_seconds
my $seconds = $ps->lease_seconds;
$ps->lease_seconds(100 * 24 * 60 * 60);
Seconds a subscription is valid by default before auto refresh
is enabled. You can not rely on auto refresh by the hub -
your subscriber should resubscribe automatically, if the hub
does not ask for renewal.
Defaults to 9 days.
=head1 METHODS
=head2 register
# Mojolicious
$app->plugin(PubSubHubbub => {
hub => 'https://hub.example.org/',
lease_seconds => 100 * 24 * 60 * 60
});
# Mojolicious::Lite
plugin 'PubSubHubbub' => {
hub => 'https://hub.example.org/',
lease_seconds => 100 * 24 * 60 * 60
};
# Or in your config file
{
PubSubHubbub => {
hub => 'https://hub.example.org/',
lease_seconds => 100 * 24 * 60 * 60
}
}
Called when registering the plugin.
Accepts the attributes mentioned as parameters.
All parameters can be set either as part of the configuration
file with the key C<PubSubHubbub> or on registration
(that can be overwritten by configuration).
=head1 SHORTCUTS
=head2 pubsub
# Mojolicious
my $r = $app->routes;
$r->route('/callback_url')->pubsub;
# Mojolicious::Lite
any('/callback_url')->pubsub;
Define the callback endpoint for your subscriptions.
Establishes an L<endpoint|Mojolicious::Plugin::Util::Endpoint>
called C<pubsub-callback>.
=head1 HELPERS
=head2 pubsub->discover
# In Controllers
my ($topic, $hub) = $c->pubsub->discover('http://sojolicio.us/');
Discover a topic feed and a hub based on a URI.
The discovery heuristics may change without notification.
=head2 pubsub->publish
# In Controllers
my $success = $c->pubsub->publish(
'my_feed', # named route
'/feed.atom', # relative paths
'https://sojolicio.us/feed.atom' # absolute URIs
);
Publish a list of feeds in terms of a notification to the hub.
Supports endpoints, named routes, relative paths and absolute URIs.
Returns a true value on success.
=head2 pubsub->subscribe
# In Controllers
if ($c->pubsub->subscribe(
topic => 'https://sojolicio.us/feed.atom',
hub => 'https://hub.sojolicio.us',
lease_seconds => 123456
)) {
print 'You successfully subscribed!';
};
Subscribe to a topic.
Relevant parameters are C<hub>,
C<lease_seconds>, C<secret>, C<verify_token>, and C<callback>.
Additional parameters are ignored but can be accessed in the hooks.
If no C<verify_token> is given, it is automatically generated.
If no C<callback> is given, the route callback is used.
If no C<lease_seconds> is given, the subscription won't
automatically terminate.
If a C<secret> is given, it must be unique for every C<callback>
and C<hub> combination to allow for bulk distribution.
The method returns a C<true> value on success and a C<false> value
if an error occured. If called in an array context, the
hub's response message body is returned additionally.
=head2 pubsub->unsubscribe
# In Controllers
if ($c->pubsub->unsubscribe(
topic => 'https://sojolicio.us/feed.atom',
hub => 'https://hub.sojolicio.us'
)) {
print 'You successfully unsubscribed!';
};
Unsubscribe from a topic.
Relevant parameters are C<hub>, C<secret>, C<verify_token>, and C<callback>.
Additional parameters are ignored but can be accessed in the hooks.
If no C<verify_token> is given, it is automatically generated.
If no C<callback> is given, the route callback is used.
The method returns a C<true> value on success and a C<false> value
if an error occured. If called in an array context, the
hub's response message body is returned additionally.
=head1 CALLBACKS
=head2 pubsub_accept
# Establish callback
$app->callback(
pubsub_accept => sub {
my ($c, $type, $topics) = @_;
# Filter topics
my @new_topics = grep($_ !~ /catz/, @$topics);
# Set secret
my $secret = 'z0idberg';
# Set X-Hub-On-Behalf-Of value
my $on_behalf = 3;
return (\@new_topics, $secret, $on_behalf);
});
This callback is released, when content arrives at the
pubsub endpoint. The parameters passed to the callback
include the current controller object, the content type,
and an array reference of topics.
Expects an array reference of maybe filtered topics,
a secret if necessary, and the value of C<X-Hub-On-Behalf-Of>.
If the returned topic list is empty, the processing will stop.
If the callback is not established, the complete content will be
processed.
The callback can be established with the
L<callback|Mojolicious::Plugin::Util::Callback/callback>
helper or on registration.
=head2 pubsub_verify
# Establish callback
$app->callback(
pubsub_verify => sub {
my ($c, $param) = @_;
# Topic is valid
if ($param->{topic} =~ /catz/ &&
$param->{verify_token} eq 'zoidberg') {
return 1;
};
# Not verified
return;
});
This callback is released, when a verification is requested.
The parameters include the current controller object and the parameters
of the verification request as a hash reference (without C<hub.>-prefix).
If verification is granted, this callback must return a true value.
The callback can be established with the
L<callback|Mojolicious::Plugin::Util::Callback/callback>
helper or on registration.
=head1 HOOKS
=head2 on_pubsub_content
$app->hook(
on_pubsub_content => sub {
my ($c, $type, $dom) = @_;
if ($type eq 'atom') {
$dom->find('entry')->each(
print $_->at('title')->text, "\n";
);
};
return;
});
This hook is released, when desired (i.e., verified and optionally
filtered) content arrives.
The parameters include the current
controller object, the content type (either C<atom> or C<rss>),
and the - maybe topic filtered - content as a L<Mojo::DOM> object.
The L<Mojo::DOM> object is canonicalized in a way that each
entry in the feed (either RSS or Atom) includes its topic in the C<href>
of C<source E<gt> link[rel="self"]>.
=head2 before_pubsub_subscribe
$app->hook(
before_pubsub_subscribe => sub {
my ($c, $params, $post) = @_;
my $topic = $params->{topic};
print "Start following $topic\n";
return;
});
This hook is released, before a subscription request is sent to a hub.
The parameters include the current controller object,
the parameters prepared for subscription as a hash reference and the C<POST>
string as a string reference.
This hook can be used to store subscription information and establish
a secret.
=head2 after_pubsub_subscribe
$app->hook(
after_pubsub_subscribe => sub {
my ($c, $hub, $params, $status, $body) = @_;
if ($status !~ /^2/) {
warn 'Error: ', $body;
};
return;
});
This hook is released, after a subscription request is sent to a hub
and the response is processed.
The parameters include the current controller object,
the hub location,
the parameters sent for subscription as a hash reference, the response status,
and the response body.
This hook can be used to deal with errors.
=head2 before_pubsub_unsubscribe
$app->hook(
before_pubsub_unsubscribe => sub {
my ($c, $params, $post) = @_;
my $topic = $params->{topic};
print "Stop following $topic\n";
return;
});
This hook is released, before an unsubscription request is sent
to a hub.
The parameters include the current controller object,
the parameters prepared for unsubscription as a hash reference and the C<POST>
string as a string reference.
This hook can be used to store unsubscription information.
=head2 after_pubsub_unsubscribe
$app->hook(
after_pubsub_unsubscribe => sub {
my ($c, $hub, $params, $status, $body) = @_;
if ($status !~ /^2/) {
warn 'Error: ', $body;
};
return;
});
This hook is released, after an unsubscription request is sent to a hub
and the response is processed.
The parameters include the current controller object,
the hub location,
the parameters sent for unsubscription as a hash reference, the response status,
and the response body.
This hook can be used to deal with errors.
=head1 EXAMPLE
The C<examples/> folder contains a full working example application with publishing,
subscription and discovery logic.
The example has additional dependencies of L<DBI>, L<DBD::SQLite> and
L<XML::Loy> (at least v0.13).
It can be started using the daemon, morbo or hypnotoad,
and needs to be accessible from the web.
$ perl examples/pubsubapp daemon
=for HTML <br /><div style="text-align: center;"><img src="http://sojolicio.us/images/pubsubhubbub-screenshot.png" alt="PubSubHubbub Example Application" /></div>
This example may be a good starting point for your own implementation, especially,
if you deal with the subscriber part.
=head1 TODO
Currently all methods are blocking. In an upcoming release all blocking
methods will allow for non-blocking as well.
=head1 DEPENDENCIES
L<Mojolicious> (best with SSL support),
L<Mojolicious::Plugin::Util::Endpoint>,
L<Mojolicious::Plugin::Util::Callback>,
L<Mojolicious::Plugin::Util::RandomString>.
=head1 AVAILABILITY
https://github.com/Akron/Mojolicious-Plugin-PubSubHubbub
This plugin is part of the L<Sojolicious|http://sojolicio.us> project.
=head1 COPYRIGHT AND LICENSE
Copyright (C) 2011-2017, L<Nils Diewald|http://nils-diewald.de/>.
This program is free software, you can redistribute it
and/or modify it under the terms of the Artistic License version 2.0.
=cut
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment