blib/lib/Mojolicious/Plugin/PubSubHubbub.pm | |||
---|---|---|---|
Criterion | Covered | Total | % |
statement | 256 | 308 | 83.1 |
branch | 106 | 172 | 61.6 |
condition | 45 | 93 | 48.3 |
subroutine | 28 | 30 | 93.3 |
pod | 3 | 5 | 60.0 |
total | 438 | 608 | 72.0 |
line | stmt | bran | cond | sub | pod | time | code |
---|---|---|---|---|---|---|---|
1 | package Mojolicious::Plugin::PubSubHubbub; | ||||||
2 | 3 | 3 | 8451 | use Mojo::Base 'Mojolicious::Plugin'; | |||
3 | 6 | ||||||
3 | 28 | ||||||
3 | 3 | 3 | 527 | use Mojo::UserAgent; | |||
3 | 14 | ||||||
3 | 28 | ||||||
4 | 3 | 3 | 94 | use Mojo::DOM; | |||
3 | 5 | ||||||
3 | 67 | ||||||
5 | 3 | 3 | 14 | use Mojo::ByteStream 'b'; | |||
3 | 4 | ||||||
3 | 160 | ||||||
6 | 3 | 3 | 20 | use Mojo::Util qw/secure_compare hmac_sha1_sum/; | |||
3 | 19 | ||||||
3 | 15300 | ||||||
7 | |||||||
8 | our $VERSION = '0.21'; | ||||||
9 | |||||||
10 | # Todo: | ||||||
11 | # - Be compliant with https://www.w3.org/TR/websub/ | ||||||
12 | # - Prevent log injection | ||||||
13 | # - Make everything async (top priority) | ||||||
14 | # - Maybe allow something like ->feed_to_json (look at superfeedr) | ||||||
15 | # - Test ->discover | ||||||
16 | |||||||
17 | # Default lease seconds before automatic subscription refreshing | ||||||
18 | has lease_seconds => ( 9 * 24 * 60 * 60 ); | ||||||
19 | has hub => 'http://pubsubhubbub.appspot.com/'; | ||||||
20 | |||||||
21 | my $FEED_TYPE_RE = qr{^(?i:application/(atom|r(?:ss|df))\+xml)}; | ||||||
22 | my $FEED_ENDING_RE = qr{(?i:\.(r(?:ss|df)|atom))$}; | ||||||
23 | |||||||
24 | # User Agent Name | ||||||
25 | my $UA_NAME = __PACKAGE__ . ' v' . $VERSION; | ||||||
26 | |||||||
27 | # Prototypes | ||||||
28 | sub _add_topics; | ||||||
29 | |||||||
30 | # Register plugin | ||||||
31 | sub register { | ||||||
32 | 3 | 3 | 1 | 4336 | my ($plugin, $mojo, $param) = @_; | ||
33 | |||||||
34 | 3 | 50 | 12 | $param ||= {}; | |||
35 | |||||||
36 | # Load parameter from Config file | ||||||
37 | 3 | 100 | 21 | if (my $config_param = $mojo->config('PubSubHubbub')) { | |||
38 | 1 | 13 | $param = { %$param, %$config_param }; | ||||
39 | }; | ||||||
40 | |||||||
41 | 3 | 51 | my $helpers = $mojo->renderer->helpers; | ||||
42 | |||||||
43 | # Load 'callback' plugin | ||||||
44 | 3 | 100 | 43 | unless (exists $helpers->{'callback'}) { | |||
45 | 2 | 11 | $mojo->plugin('Util::Callback'); | ||||
46 | }; | ||||||
47 | |||||||
48 | # Set callbacks on registration | ||||||
49 | 3 | 3321 | $mojo->callback([qw/pubsub_accept pubsub_verify/] => $param); | ||||
50 | |||||||
51 | # Load 'endpoint' plugin | ||||||
52 | 3 | 100 | 542 | unless (exists $helpers->{'endpoint'}) { | |||
53 | 2 | 10 | $mojo->plugin('Util::Endpoint'); | ||||
54 | }; | ||||||
55 | |||||||
56 | # Load 'randomstring' plugin | ||||||
57 | 3 | 6509 | $mojo->plugin('Util::RandomString' => { | ||||
58 | pubsub_challenge => { | ||||||
59 | length => 12, | ||||||
60 | alphabet => [ 'A' .. 'Z', 'a' .. 'z', 0 .. 9 ] | ||||||
61 | } | ||||||
62 | }); | ||||||
63 | |||||||
64 | # Set hub attribute | ||||||
65 | 3 | 100 | 11093 | if ($param->{hub}) { | |||
66 | 2 | 11 | $plugin->hub($param->{hub}); | ||||
67 | }; | ||||||
68 | |||||||
69 | # Establish an endpoint | ||||||
70 | 3 | 24 | $mojo->endpoint('pubsub-hub' => $plugin->hub); | ||||
71 | |||||||
72 | # Set lease_seconds attribute | ||||||
73 | 3 | 100 | 815 | if ($param->{lease_seconds}) { | |||
74 | 1 | 4 | $plugin->lease_seconds($param->{lease_seconds}); | ||||
75 | }; | ||||||
76 | |||||||
77 | # Add 'pubsub' shortcut | ||||||
78 | $mojo->routes->add_shortcut( | ||||||
79 | pubsub => sub { | ||||||
80 | 2 | 2 | 1643 | my ($route, $param) = @_; | |||
81 | |||||||
82 | # Set param default to 'cb' | ||||||
83 | 2 | 100 | 13 | $param ||= 'cb'; | |||
84 | |||||||
85 | # 'hub' is currently not supported | ||||||
86 | 2 | 100 | 57 | return unless $param eq 'cb'; | |||
87 | |||||||
88 | # Set PubSubHubbub endpoints | ||||||
89 | 1 | 12 | $route->endpoint('pubsub-callback'); | ||||
90 | |||||||
91 | # Add 'callback' route | ||||||
92 | $route->to( | ||||||
93 | cb => sub { | ||||||
94 | 18 | 205515 | my $c = shift; | ||||
95 | |||||||
96 | # Hook on verification | ||||||
97 | 18 | 100 | 68 | return $plugin->verify($c) if $c->param('hub.mode'); | |||
98 | |||||||
99 | # Hook on callback | ||||||
100 | 9 | 1960 | return $plugin->callback($c); | ||||
101 | 1 | 62 | }); | ||||
102 | 3 | 19 | }); | ||||
103 | |||||||
104 | # Return plugin object | ||||||
105 | $mojo->helper( | ||||||
106 | 'pubsub._plugin' => sub { | ||||||
107 | 2 | 2 | 1146 | $plugin; | |||
108 | 3 | 184 | }); | ||||
109 | |||||||
110 | $mojo->helper( | ||||||
111 | 'pubsub.publish' => sub { | ||||||
112 | 3 | 3 | 7607 | $plugin->publish( @_ ); | |||
113 | 3 | 1041 | }); | ||||
114 | |||||||
115 | # Add 'subscribe' and 'unsubscribe' helper | ||||||
116 | 3 | 818 | foreach my $action (qw(subscribe unsubscribe)) { | ||||
117 | $mojo->helper( | ||||||
118 | "pubsub.${action}" => sub { | ||||||
119 | 8 | 8 | 9504 | $plugin->_change_subscription(shift, mode => $action, @_); | |||
120 | 6 | 873 | }); | ||||
121 | }; | ||||||
122 | |||||||
123 | $mojo->helper( | ||||||
124 | 'pubsub.discover' => sub { | ||||||
125 | 0 | 0 | 0 | $plugin->discover( @_ ) | |||
126 | } | ||||||
127 | 3 | 950 | ); | ||||
128 | }; | ||||||
129 | |||||||
130 | |||||||
131 | # Ping a hub for topics | ||||||
132 | sub publish { | ||||||
133 | 3 | 3 | 1 | 8 | my $plugin = shift; | ||
134 | 3 | 7 | my $c = shift; | ||||
135 | |||||||
136 | # Nothing to publish or no hub defined | ||||||
137 | 3 | 100 | 66 | 19 | return unless @_ || !$plugin->hub; | ||
138 | |||||||
139 | # Set all urls | ||||||
140 | 2 | 11 | my @urls = map($c->endpoint($_), @_); | ||||
141 | |||||||
142 | # Create post message | ||||||
143 | 2 | 1827 | my %post = ( | ||||
144 | 'hub.mode' => 'publish', | ||||||
145 | 'hub.url' => \@urls | ||||||
146 | ); | ||||||
147 | |||||||
148 | # Get user agent | ||||||
149 | 2 | 15 | my $ua = Mojo::UserAgent->new( | ||||
150 | max_redirects => 3, | ||||||
151 | name => $UA_NAME | ||||||
152 | ); | ||||||
153 | |||||||
154 | 2 | 21 | my $msg = 'Cannot ping hub'; | ||||
155 | 2 | 50 | 10 | $msg .= ' - maybe no SSL support' if index($plugin->hub, 'https') == 0; | |||
156 | |||||||
157 | # Blocking | ||||||
158 | # Post to hub | ||||||
159 | 2 | 21 | my $tx = $ua->post( $plugin->hub => form => \%post ); | ||||
160 | |||||||
161 | 2 | 28388 | my $res = $tx->result; | ||||
162 | |||||||
163 | # No response | ||||||
164 | 2 | 50 | 55 | unless ($res) { | |||
165 | 0 | 0 | $c->app->log->warn($msg); | ||||
166 | 0 | 0 | return; | ||||
167 | }; | ||||||
168 | |||||||
169 | # is 2xx, incl. 204 aka successful | ||||||
170 | 2 | 50 | 7 | return 1 if $res->is_success; | |||
171 | |||||||
172 | # Not successful | ||||||
173 | 0 | 0 | return; | ||||
174 | }; | ||||||
175 | |||||||
176 | |||||||
177 | # Verify a changed subscription or automatically refresh | ||||||
178 | sub verify { | ||||||
179 | 9 | 9 | 0 | 3352 | my $plugin = shift; | ||
180 | 9 | 22 | my $c = shift; | ||||
181 | |||||||
182 | # Good request | ||||||
183 | 9 | 100 | 100 | 29 | if ($c->param('hub.topic') && | ||
100 | |||||||
184 | $c->param('hub.challenge') && | ||||||
185 | $c->param('hub.mode') =~ /^(?:un)?subscribe$/) { | ||||||
186 | |||||||
187 | 4 | 804 | my $challenge = $c->param('hub.challenge'); | ||||
188 | |||||||
189 | 4 | 236 | my %param; | ||||
190 | 4 | 14 | foreach (qw/mode | ||||
191 | topic | ||||||
192 | verify | ||||||
193 | lease_seconds | ||||||
194 | verify_token/) { | ||||||
195 | 20 | 100 | 1431 | $param{$_} = $c->param("hub.$_") if $c->param("hub.$_"); | |||
196 | }; | ||||||
197 | |||||||
198 | # Get verification callback | ||||||
199 | 4 | 231 | my $ok = $c->callback( | ||||
200 | pubsub_verify => \%param | ||||||
201 | ); | ||||||
202 | |||||||
203 | # Render challenge | ||||||
204 | 4 | 100 | 3140 | return $c->render( | |||
205 | 'status' => 200, | ||||||
206 | 'format' => 'txt', | ||||||
207 | 'data' => $challenge | ||||||
208 | ) if $ok; | ||||||
209 | }; | ||||||
210 | |||||||
211 | # Not found | ||||||
212 | 7 | 675 | return $c->reply->not_found; | ||||
213 | }; | ||||||
214 | |||||||
215 | |||||||
216 | # Discover links from header | ||||||
217 | # This is extremely simplified from https://tools.ietf.org/html/rfc5988 | ||||||
218 | sub _discover_header_links { | ||||||
219 | 1 | 1 | 970 | my $header = shift; | |||
220 | |||||||
221 | 1 | 3 | my $header_hash = $header->to_hash(1); | ||||
222 | |||||||
223 | 1 | 50 | 22 | my @links = (@{$header_hash->{Link} // []}, @{$header_hash->{link} // []}); | |||
1 | 50 | 4 | |||||
1 | 8 | ||||||
224 | 1 | 2 | my %links; | ||||
225 | |||||||
226 | # Iterate through all header links | ||||||
227 | 1 | 2 | foreach (@links) { | ||||
228 | |||||||
229 | # Make multiline headers one line | ||||||
230 | 11 | 50 | 19 | $_ = join(' ', @$_) if ref $_; | |||
231 | |||||||
232 | # Check for link with correct relation | ||||||
233 | 11 | 100 | 61 | if ($_ =~ /^\<([^>]+?)\>(.*?rel\s*=\s*"(self|hub|alternate)".*?)$/mi) { | |||
234 | |||||||
235 | # Create new link hash | ||||||
236 | 7 | 22 | my %link = ( href => $1, rel => $3 ); | ||||
237 | |||||||
238 | # There may be more than one reference | ||||||
239 | 7 | 17 | my $check = $2; | ||||
240 | |||||||
241 | # Set type | ||||||
242 | 7 | 100 | 31 | if ($check =~ /type\s*=\s*"([^"]+?)"/omi) { | |||
243 | 4 | 7 | my $type = $1; | ||||
244 | 4 | 50 | 33 | 35 | next if $type && $type !~ $FEED_TYPE_RE; | ||
245 | 4 | 10 | $link{type} = $type; | ||||
246 | 4 | 8 | $link{short_type} = $1; | ||||
247 | }; | ||||||
248 | |||||||
249 | # Set title | ||||||
250 | 7 | 100 | 33 | if ($check =~ /title\s*=\s*"([^"]+?)"/omi) { | |||
251 | 5 | 10 | $link{title} = $1; | ||||
252 | }; | ||||||
253 | |||||||
254 | # Check file ending for short type | ||||||
255 | 7 | 100 | 13 | unless ($link{short_type}) { | |||
256 | 3 | 100 | 19 | $link{short_type} = $1 if $link{href} =~ $FEED_ENDING_RE; | |||
257 | }; | ||||||
258 | |||||||
259 | # Push found link | ||||||
260 | 7 | 11 | my $rel = $link{rel}; | ||||
261 | 7 | 100 | 21 | $links{$rel} //= []; | |||
262 | 7 | 9 | push(@{$links{$rel}}, \%link); | ||||
7 | 17 | ||||||
263 | }; | ||||||
264 | }; | ||||||
265 | |||||||
266 | # Return array | ||||||
267 | 1 | 31 | return \%links; | ||||
268 | }; | ||||||
269 | |||||||
270 | |||||||
271 | # Discover links from dom tree | ||||||
272 | sub _discover_dom_links { | ||||||
273 | 2 | 2 | 5411 | my $dom = shift; | |||
274 | |||||||
275 | 2 | 3 | my %links; | ||||
276 | |||||||
277 | # Find alternate representations | ||||||
278 | $dom->find('link[rel="alternate"], link[rel="self"], link[rel="hub"]')->each( | ||||||
279 | sub { | ||||||
280 | 13 | 13 | 4154 | my ($href, $rel, $type, $title) = @{$_->attr}{qw/href rel type title/}; | |||
13 | 27 | ||||||
281 | |||||||
282 | # Is no supported type | ||||||
283 | 13 | 50 | 66 | 205 | return if $type && $type !~ $FEED_TYPE_RE; | ||
284 | |||||||
285 | # Set short type | ||||||
286 | 13 | 100 | 31 | my $short_type = $1 if $1; | |||
287 | |||||||
288 | 13 | 50 | 33 | 37 | return unless $href && $rel; | ||
289 | |||||||
290 | # Create new link hash | ||||||
291 | 13 | 31 | my %link = ( href => $href, rel => $rel ); | ||||
292 | |||||||
293 | # Short type yet not known | ||||||
294 | 13 | 100 | 18 | unless ($short_type) { | |||
295 | |||||||
296 | # Set short type by file ending | ||||||
297 | 5 | 100 | 26 | $link{short_type} = $1 if $href =~ m/\.(r(?:ss|df)|atom)$/i; | |||
298 | } | ||||||
299 | |||||||
300 | # Set short type | ||||||
301 | else { | ||||||
302 | 8 | 14 | $link{short_type} = $short_type; | ||||
303 | }; | ||||||
304 | |||||||
305 | # Set title and type | ||||||
306 | 13 | 100 | 25 | $link{title} = $title if $title; | |||
307 | 13 | 100 | 21 | $link{type} = $type if $type; | |||
308 | |||||||
309 | # Push found link | ||||||
310 | 13 | 100 | 35 | $links{$rel} //= []; | |||
311 | 13 | 15 | push(@{$links{$rel}}, \%link); | ||||
13 | 47 | ||||||
312 | } | ||||||
313 | 2 | 6 | ); | ||||
314 | |||||||
315 | # Return array | ||||||
316 | 2 | 33 | return \%links; | ||||
317 | }; | ||||||
318 | |||||||
319 | |||||||
320 | # Heuristically sort links to best match the topic | ||||||
321 | sub _discover_sort_links { | ||||||
322 | 3 | 3 | 5 | my $links = shift; | |||
323 | |||||||
324 | 3 | 5 | my ($topic, $hub); | ||||
325 | |||||||
326 | # Get self link as topic | ||||||
327 | 3 | 100 | 8 | if ($links->{self}) { | |||
328 | |||||||
329 | # Find best match of all returned links | ||||||
330 | 2 | 3 | foreach my $link (@{$links->{self}}) { | ||||
2 | 4 | ||||||
331 | 2 | 33 | 9 | $topic ||= $link; | |||
332 | 2 | 50 | 33 | 5 | if ($link->{short_type} && !$topic->{short_type}) { | ||
333 | 0 | 0 | $topic = $link; | ||||
334 | }; | ||||||
335 | }; | ||||||
336 | }; | ||||||
337 | |||||||
338 | # Get hub | ||||||
339 | 3 | 50 | 7 | if ($links->{hub}) { | |||
340 | |||||||
341 | # Find best match of all returned links | ||||||
342 | 3 | 5 | foreach my $link (@{$links->{hub}}) { | ||||
3 | 6 | ||||||
343 | 3 | 33 | 12 | $hub ||= $link; | |||
344 | 3 | 50 | 33 | 20 | if ($link->{short_type} && !$hub->{short_type}) { | ||
345 | 0 | 0 | $hub = $link; | ||||
346 | }; | ||||||
347 | }; | ||||||
348 | }; | ||||||
349 | |||||||
350 | # Already found topic and hub | ||||||
351 | 3 | 100 | 66 | 13 | return ($topic, $hub) if $topic && $hub; | ||
352 | |||||||
353 | # Check alternates | ||||||
354 | 1 | 2 | my $alternate = $links->{alternate}; | ||||
355 | |||||||
356 | # Search in alternate representations for best match | ||||||
357 | 1 | 50 | 3 | if ($alternate) { | |||
358 | |||||||
359 | # Iterate through all alternate links | ||||||
360 | # and check their titles | ||||||
361 | 1 | 2 | foreach my $link (@$alternate) { | ||||
362 | |||||||
363 | # No title given | ||||||
364 | 5 | 50 | 20 | unless ($link->{title}) { | |||
50 | |||||||
365 | 0 | 0 | $link->{pref} = 2; | ||||
366 | } | ||||||
367 | |||||||
368 | # Guess which feed is best based on the title | ||||||
369 | 0 | 0 | elsif ($link->{title} =~ /(?i:feed|stream)/i) { | ||||
370 | |||||||
371 | # This is more likely a comment feed | ||||||
372 | 5 | 100 | 9 | if ($link->{title} =~ /[ck]omment/i) { | |||
373 | 2 | 5 | $link->{pref} = 1; | ||||
374 | } | ||||||
375 | |||||||
376 | # This may be the correct feed | ||||||
377 | else { | ||||||
378 | 3 | 6 | $link->{pref} = 3; | ||||
379 | }; | ||||||
380 | } | ||||||
381 | |||||||
382 | # Don't know ... | ||||||
383 | else { | ||||||
384 | 0 | 0 | $link->{pref} = 2; | ||||
385 | }; | ||||||
386 | }; | ||||||
387 | |||||||
388 | # Get best topic | ||||||
389 | ($topic) = (sort { | ||||||
390 | |||||||
391 | # Sort by title | ||||||
392 | 1 | 100 | 4 | if ($a->{pref} < $b->{pref}) { | |||
8 | 100 | 19 | |||||
50 | |||||||
0 | |||||||
0 | |||||||
0 | |||||||
393 | 3 | 4 | return 1; | ||||
394 | } | ||||||
395 | elsif ($a->{pref} > $b->{pref}) { | ||||||
396 | 1 | 2 | return -1; | ||||
397 | } | ||||||
398 | # Sort by type | ||||||
399 | elsif ($a->{short_type} gt $b->{short_type}) { | ||||||
400 | 4 | 7 | return 1; | ||||
401 | } | ||||||
402 | elsif ($a->{short_type} lt $b->{short_type}) { | ||||||
403 | 0 | 0 | return -1; | ||||
404 | } | ||||||
405 | # Sort by length | ||||||
406 | elsif (length($a->{href}) > length($b->{href})) { | ||||||
407 | 0 | 0 | return 1; | ||||
408 | } | ||||||
409 | elsif (length($a->{href}) <= length($b->{href})) { | ||||||
410 | 0 | 0 | return -1; | ||||
411 | } | ||||||
412 | # Equal | ||||||
413 | else { | ||||||
414 | 0 | 0 | return -1; | ||||
415 | }; | ||||||
416 | } @$alternate); | ||||||
417 | }; | ||||||
418 | |||||||
419 | # Maybe empty ... maybe not | ||||||
420 | 1 | 4 | return ($topic, $hub); | ||||
421 | }; | ||||||
422 | |||||||
423 | |||||||
424 | # Discover topic and hub based on a URI | ||||||
425 | # That's a rather complex heuristic, but should gain good results | ||||||
426 | sub discover { | ||||||
427 | 0 | 0 | 1 | 0 | my $plugin = shift; | ||
428 | 0 | 0 | my $c = shift; | ||||
429 | |||||||
430 | # No uri given | ||||||
431 | 0 | 0 | 0 | return () unless $_[0]; | |||
432 | |||||||
433 | # Get uri | ||||||
434 | 0 | 0 | 0 | my $base = Mojo::URL->new( shift ) or return (); | |||
435 | |||||||
436 | # Set base to uri | ||||||
437 | 0 | 0 | $base->base($c->req->url); | ||||
438 | |||||||
439 | # Initialize UserAgent | ||||||
440 | 0 | 0 | my $ua = Mojo::UserAgent->new( | ||||
441 | max_redirects => 3, | ||||||
442 | name => $UA_NAME | ||||||
443 | ); | ||||||
444 | |||||||
445 | # Initialize variables | ||||||
446 | 0 | 0 | my ($hub, $topic, $nbase, $ntopic); | ||||
447 | |||||||
448 | # Retrieve resource | ||||||
449 | 0 | 0 | my $tx = $ua->get($base); | ||||
450 | |||||||
451 | 0 | 0 | 0 | unless ($tx->error) { | |||
452 | |||||||
453 | # Change base after possible redirects | ||||||
454 | 0 | 0 | $base = $tx->req->url; | ||||
455 | |||||||
456 | # Get response | ||||||
457 | 0 | 0 | my $res = $tx->res; | ||||
458 | |||||||
459 | # Check sorted header links | ||||||
460 | 0 | 0 | ($topic, $hub) = _discover_sort_links( | ||||
461 | _discover_header_links($res->headers) | ||||||
462 | ); | ||||||
463 | |||||||
464 | # Fine | ||||||
465 | 0 | 0 | 0 | 0 | unless ($topic && $hub) { | ||
466 | |||||||
467 | 0 | 0 | my $dom = $res->dom; | ||||
468 | |||||||
469 | # Check sorted dom links | ||||||
470 | 0 | 0 | ($topic, $hub) = _discover_sort_links( | ||||
471 | _discover_dom_links($dom) | ||||||
472 | ); | ||||||
473 | }; | ||||||
474 | |||||||
475 | # Fine | ||||||
476 | 0 | 0 | 0 | 0 | if ($topic && !$hub) { | ||
477 | |||||||
478 | # Initialize new UserAgent | ||||||
479 | 0 | 0 | $ua = Mojo::UserAgent->new( | ||||
480 | max_redirects => 3, | ||||||
481 | name => $UA_NAME | ||||||
482 | ); | ||||||
483 | |||||||
484 | # Set new base base | ||||||
485 | 0 | 0 | $nbase = Mojo::URL->new($topic->{href})->base($base)->to_abs; | ||||
486 | |||||||
487 | # Retrieve resource | ||||||
488 | 0 | 0 | $tx = $ua->get($nbase); | ||||
489 | |||||||
490 | # Request was successful | ||||||
491 | 0 | 0 | 0 | unless ($tx->error) { | |||
492 | |||||||
493 | # Change nbase after possible redirects | ||||||
494 | 0 | 0 | $nbase = $tx->req->url; | ||||
495 | |||||||
496 | # Get response | ||||||
497 | 0 | 0 | $res = $tx->res; | ||||
498 | |||||||
499 | # Check sorted header links | ||||||
500 | 0 | 0 | ($ntopic, $hub) = _discover_sort_links( | ||||
501 | _discover_header_links($res->headers) | ||||||
502 | ); | ||||||
503 | |||||||
504 | |||||||
505 | 0 | 0 | 0 | 0 | unless ($ntopic && $hub) { | ||
506 | |||||||
507 | # Check sorted dom links | ||||||
508 | 0 | 0 | ($ntopic, $hub) = _discover_sort_links( | ||||
509 | _discover_dom_links($res->dom) | ||||||
510 | ); | ||||||
511 | }; | ||||||
512 | } | ||||||
513 | |||||||
514 | # Reset nbase as no connection occurred | ||||||
515 | else { | ||||||
516 | 0 | 0 | $nbase = undef; | ||||
517 | }; | ||||||
518 | }; | ||||||
519 | }; | ||||||
520 | |||||||
521 | # Make relative path for topics and hubs absolute | ||||||
522 | 0 | 0 | 0 | 0 | $hub = Mojo::URL->new($hub->{href})->base( $nbase || $base )->to_abs if $hub; | ||
523 | |||||||
524 | # New topic is set | ||||||
525 | 0 | 0 | 0 | if ($ntopic) { | |||
0 | |||||||
526 | 0 | 0 | $topic = Mojo::URL->new($ntopic->{href})->base($nbase)->to_abs; | ||||
527 | } | ||||||
528 | |||||||
529 | # Old topic is set | ||||||
530 | elsif ($topic) { | ||||||
531 | 0 | 0 | $topic = Mojo::URL->new($topic->{href})->base($base)->to_abs; | ||||
532 | }; | ||||||
533 | |||||||
534 | # Return | ||||||
535 | 0 | 0 | return ($topic, $hub); | ||||
536 | }; | ||||||
537 | |||||||
538 | |||||||
539 | # subscribe or unsubscribe from a topic | ||||||
540 | sub _change_subscription { | ||||||
541 | 8 | 8 | 18 | my $plugin = shift; | |||
542 | 8 | 14 | my $c = shift; | ||||
543 | 8 | 33 | my %param = @_; | ||||
544 | |||||||
545 | 8 | 26 | my $log = $c->app->log; | ||||
546 | |||||||
547 | # Get callback endpoint | ||||||
548 | # Works only if endpoints provided | ||||||
549 | 8 | 50 | 33 | 87 | unless ($param{callback} ||= $c->endpoint('pubsub-callback')) { | ||
550 | 0 | 0 | 0 | $log->error('You have to specify a callback endpoint') and return; | |||
551 | }; | ||||||
552 | |||||||
553 | # No topic or hub url given | ||||||
554 | 8 | 100 | 100 | 6104 | unless (exists $param{topic} && | ||
100 | |||||||
555 | $param{topic} =~ m{^https?://}i && | ||||||
556 | exists $param{hub}) { | ||||||
557 | 4 | 42 | $log->warn('You have to specify a topic and a hub'); | ||||
558 | 4 | 70 | return; | ||||
559 | }; | ||||||
560 | |||||||
561 | 4 | 13 | my $mode = $param{mode}; | ||||
562 | |||||||
563 | # delete lease seconds if no integer | ||||||
564 | 4 | 0 | 0 | 14 | if (exists $param{lease_seconds} && | ||
33 | |||||||
565 | ($mode eq 'unsubscribe' || $param{lease_seconds} !~ /^\d+$/) | ||||||
566 | ) { | ||||||
567 | 0 | 0 | delete $param{lease_seconds}; | ||||
568 | }; | ||||||
569 | |||||||
570 | # Set to default | ||||||
571 | 4 | 100 | 33 | 22 | $param{lease_seconds} ||= $plugin->lease_seconds if $mode eq 'subscribe'; | ||
572 | |||||||
573 | # Render post string | ||||||
574 | 4 | 24 | my %post = ( callback => $param{callback} ); | ||||
575 | 4 | 11 | foreach ( qw/mode topic verify lease_seconds secret/ ) { | ||||
576 | 20 | 50 | 66 | 64 | $post{ $_ } = $param{ $_ } if exists $param{ $_ } && $param{ $_ }; | ||
577 | }; | ||||||
578 | |||||||
579 | # Use verify token | ||||||
580 | $post{verify_token} = | ||||||
581 | exists $param{verify_token} ? | ||||||
582 | $param{verify_token} : | ||||||
583 | ($param{verify_token} = | ||||||
584 | 4 | 50 | 36 | $c->random_string('pubsub_challenge')); | |||
585 | |||||||
586 | 4 | 214 | $post{verify} = "${_}sync" foreach ('a', ''); | ||||
587 | |||||||
588 | 4 | 13 | my $mojo = $c->app; | ||||
589 | |||||||
590 | 4 | 26 | $mojo->plugins->emit_hook( | ||||
591 | "before_pubsub_$mode" => ($c, \%param, \%post) | ||||||
592 | ); | ||||||
593 | |||||||
594 | # Prefix all parameters | ||||||
595 | 4 | 1332 | %post = map { 'hub.' . $_ => $post{$_} } keys %post; | ||||
22 | 80 | ||||||
596 | |||||||
597 | # Get user agent | ||||||
598 | 4 | 32 | my $ua = Mojo::UserAgent->new( | ||||
599 | max_redirects => 3, | ||||||
600 | name => $UA_NAME | ||||||
601 | ); | ||||||
602 | |||||||
603 | # Send subscription change to hub | ||||||
604 | 4 | 44 | my $tx = $ua->post($param{hub} => form => \%post); | ||||
605 | |||||||
606 | 4 | 64910 | my $res = $tx->result; | ||||
607 | |||||||
608 | # No response | ||||||
609 | 4 | 50 | 120 | unless ($res) { | |||
610 | 0 | 0 | my $msg = 'Cannot ping hub'; | ||||
611 | 0 | 0 | 0 | $msg .= ' - maybe no SSL support' if index($param{hub}, 'https') == 0; | |||
612 | 0 | 0 | $log->warn($msg); | ||||
613 | 0 | 0 | return; | ||||
614 | }; | ||||||
615 | |||||||
616 | $mojo->plugins->emit_hook( | ||||||
617 | "after_pubsub_$mode" => ( | ||||||
618 | 4 | 14 | $c, $param{hub}, \%post, $res->code, $res->body | ||||
619 | )); | ||||||
620 | |||||||
621 | # is 2xx, incl. 204 aka successful and 202 aka accepted | ||||||
622 | 4 | 100 | 5046 | my $success = $res->is_success ? 1 : 0; | |||
623 | |||||||
624 | 4 | 50 | 87 | return ($success, $res->{body}) if wantarray; | |||
625 | 4 | 23 | return $success; | ||||
626 | }; | ||||||
627 | |||||||
628 | |||||||
629 | # Incoming data callback | ||||||
630 | sub callback { | ||||||
631 | 9 | 9 | 0 | 19 | my $plugin = shift; | ||
632 | 9 | 15 | my $c = shift; | ||||
633 | 9 | 24 | my $log = $c->app->log; | ||||
634 | |||||||
635 | 9 | 100 | 70 | my $ct = $c->req->headers->header('Content-Type') || 'unknown'; | |||
636 | 9 | 281 | my $type; | ||||
637 | |||||||
638 | # Is Atom | ||||||
639 | 9 | 100 | 52 | if ($ct =~ m{^application/atom\+xml}) { | |||
100 | |||||||
640 | 4 | 7 | $type = 'atom'; | ||||
641 | } | ||||||
642 | |||||||
643 | # Is RSS | ||||||
644 | elsif ($ct =~ m{^application/r(?:ss|df)\+xml}) { | ||||||
645 | 3 | 26 | $type = 'rss'; | ||||
646 | } | ||||||
647 | |||||||
648 | # Unsupported content type | ||||||
649 | else { | ||||||
650 | 2 | 100 | 11 | $log->warn("Unsupported media type: $ct") if $c->req->body; | |||
651 | 2 | 103 | return _render_fail($c); | ||||
652 | }; | ||||||
653 | |||||||
654 | 7 | 35 | my $dom = Mojo::DOM->new(xml => 1, charset => 'UTF-8'); | ||||
655 | |||||||
656 | # Parse fat ping | ||||||
657 | 7 | 557 | $dom->parse(b($c->req->body)->decode->to_string); | ||||
658 | |||||||
659 | # Find topics in Payload | ||||||
660 | 7 | 22859 | my $topics = _find_topics($type, $dom); | ||||
661 | |||||||
662 | # No topics to process - but technically fine | ||||||
663 | 7 | 50 | 23 | return _render_success($c) unless $topics->[0]; | |||
664 | |||||||
665 | # Save unfiltered topics for later comparison | ||||||
666 | 7 | 23 | my @old_topics = @$topics; | ||||
667 | |||||||
668 | # Check for secret and which topics are wanted | ||||||
669 | 7 | 33 | ($topics, my $secret, my $x_hub_on_behalf_of) = | ||||
670 | $c->callback(pubsub_accept => $type, $topics); | ||||||
671 | |||||||
672 | 7 | 50 | 17498 | $x_hub_on_behalf_of ||= 1; | |||
673 | |||||||
674 | # No topics to process | ||||||
675 | # return _render_success( $c => $x_hub_on_behalf_of ) | ||||||
676 | 7 | 50 | 33 | return _render_success( $c => 1 ) unless scalar @$topics; | |||
677 | |||||||
678 | # Todo: Async with on(finish => ..) | ||||||
679 | |||||||
680 | # Secret is needed | ||||||
681 | 7 | 100 | 35 | if ($secret) { | |||
682 | |||||||
683 | # Unable to verify secret | ||||||
684 | 3 | 100 | 11 | unless ( _check_signature( $c, $secret )) { | |||
685 | |||||||
686 | 2 | 31 | $log->debug( | ||||
687 | 'Unable to verify secret for ' . join('; ', @$topics) | ||||||
688 | ); | ||||||
689 | |||||||
690 | # return _render_success( $c => $x_hub_on_behalf_of ); | ||||||
691 | 2 | 21 | return _render_success( $c => 1 ); | ||||
692 | }; | ||||||
693 | }; | ||||||
694 | |||||||
695 | # Some topics are unwanted | ||||||
696 | 5 | 100 | 59 | if (@$topics != @old_topics) { | |||
697 | |||||||
698 | # filter dom based on topics | ||||||
699 | 4 | 15 | $topics = _filter_topics($dom, $topics); | ||||
700 | }; | ||||||
701 | |||||||
702 | 5 | 25 | $c->app->plugins->emit_hook( | ||||
703 | on_pubsub_content => $c, $type, $dom | ||||||
704 | ); | ||||||
705 | |||||||
706 | # Successful | ||||||
707 | 5 | 1929 | return _render_success( $c => $x_hub_on_behalf_of ); | ||||
708 | }; | ||||||
709 | |||||||
710 | |||||||
711 | # Find topics of entries | ||||||
712 | sub _find_topics { | ||||||
713 | 10 | 10 | 23058 | my $type = shift; | |||
714 | 10 | 21 | my $dom = shift; | ||||
715 | |||||||
716 | # Get all source links | ||||||
717 | 10 | 34 | my $links = $dom->find('source > link[rel="self"][href]'); | ||||
718 | |||||||
719 | # Save href as topics | ||||||
720 | 10 | 50 | 10 | 13751 | my @topics = @{ $links->map( sub { $_->attr('href') } ) } if $links; | ||
10 | 63 | ||||||
10 | 86 | ||||||
721 | |||||||
722 | # Find all entries, regardless if rss or atom | ||||||
723 | 10 | 304 | my $entries = $dom->find('item, feed > entry'); | ||||
724 | |||||||
725 | # Not every entry has a source | ||||||
726 | 10 | 50 | 14918 | if ($links->size != $entries->size) { | |||
727 | |||||||
728 | # One feed or entry | ||||||
729 | 10 | 105 | my $link = $dom->at( | ||||
730 | 'feed > link[rel="self"][href],' . | ||||||
731 | 'channel > link[rel="self"][href]' | ||||||
732 | ); | ||||||
733 | |||||||
734 | 10 | 11129 | my $self_href; | ||||
735 | |||||||
736 | # Channel or feed link | ||||||
737 | 10 | 50 | 0 | 47 | if ($link) { | ||
0 | |||||||
738 | 10 | 72 | $self_href = $link->attr('href'); | ||||
739 | } | ||||||
740 | |||||||
741 | # Source of first item in RSS | ||||||
742 | elsif (!$self_href && $type eq 'rss') { | ||||||
743 | |||||||
744 | # Possible | ||||||
745 | 0 | 0 | $link = $dom->at('item > source'); | ||||
746 | 0 | 0 | 0 | $self_href = $link->attr('url') if $link; | |||
747 | }; | ||||||
748 | |||||||
749 | # Add topic to all entries | ||||||
750 | 10 | 50 | 192 | _add_topics($type, $dom, $self_href) if $self_href; | |||
751 | |||||||
752 | # Get all source links | ||||||
753 | 10 | 39 | $links = $dom->find('source > link[rel="self"][href]'); | ||||
754 | |||||||
755 | # Save href as topics | ||||||
756 | 10 | 50 | 30 | 16022 | @topics = @{ $links->map( sub { $_->attr('href') } ) } if $links; | ||
10 | 66 | ||||||
30 | 410 | ||||||
757 | }; | ||||||
758 | |||||||
759 | # Unify list | ||||||
760 | 10 | 50 | 243 | if (@topics > 1) { | |||
761 | 10 | 25 | my %topics = map { $_ => 1 } @topics; | ||||
30 | 86 | ||||||
762 | 10 | 69 | @topics = sort keys %topics; | ||||
763 | }; | ||||||
764 | |||||||
765 | 10 | 78 | return \@topics; | ||||
766 | }; | ||||||
767 | |||||||
768 | |||||||
769 | # Add topic to entries | ||||||
770 | sub _add_topics { | ||||||
771 | 13 | 13 | 25968 | state $atom_ns = 'http://www.w3.org/2005/Atom'; | |||
772 | |||||||
773 | 13 | 31 | my ($type, $dom, $self_href) = @_; | ||||
774 | |||||||
775 | 13 | 43 | my $link = qq{}; | ||||
776 | |||||||
777 | # Add source information to each entry | ||||||
778 | $dom->find('item, entry')->each( | ||||||
779 | sub { | ||||||
780 | 39 | 39 | 23787 | my $entry = shift; | |||
781 | 39 | 68 | my $source; | ||||
782 | |||||||
783 | # Sources are found | ||||||
784 | 39 | 50 | 114 | if (my $sources = $entry->find('source')) { | |||
785 | 39 | 10034 | foreach my $s (@$sources) { | ||||
786 | 26 | 50 | 50 | 72 | $source = $s and last if $s->namespace eq $atom_ns; | ||
787 | }; | ||||||
788 | }; | ||||||
789 | |||||||
790 | # No source found | ||||||
791 | 39 | 100 | 66 | 1233 | unless ($source) { | ||
792 | 13 | 65 | $source = $entry->append_content(qq{}) | ||||
793 | ->at(qq{source[xmlns="$atom_ns"]}); | ||||||
794 | } | ||||||
795 | |||||||
796 | # Link already there | ||||||
797 | elsif ($source->at('link[rel="self"][href]')) { | ||||||
798 | return $dom; | ||||||
799 | }; | ||||||
800 | |||||||
801 | # Add link | ||||||
802 | 26 | 12047 | $source->append_content( $link ); | ||||
803 | 13 | 45 | }); | ||||
804 | |||||||
805 | 13 | 171 | return $dom; | ||||
806 | }; | ||||||
807 | |||||||
808 | |||||||
809 | # filter entries based on their topic | ||||||
810 | sub _filter_topics { | ||||||
811 | 7 | 7 | 6997 | my $dom = shift; | |||
812 | |||||||
813 | 7 | 14 | my %allowed = map { $_ => 1 } @{ shift(@_) }; | ||||
7 | 37 | ||||||
7 | 21 | ||||||
814 | |||||||
815 | 7 | 32 | my $links = $dom->find( | ||||
816 | 'feed > entry > source > link[rel="self"][href],' . | ||||||
817 | 'item > source > link[rel="self"][href]' | ||||||
818 | ); | ||||||
819 | |||||||
820 | 7 | 20649 | my %topics; | ||||
821 | |||||||
822 | # Delete entries that are not allowed | ||||||
823 | $links->each( | ||||||
824 | sub { | ||||||
825 | 21 | 21 | 3253 | my $l = shift; | |||
826 | 21 | 58 | my $href = $l->attr('href'); | ||||
827 | |||||||
828 | # entry is not allowed | ||||||
829 | 21 | 100 | 358 | unless (exists $allowed{$href}) { | |||
830 | 14 | 62 | $l->parent->parent->replace(''); | ||||
831 | } | ||||||
832 | |||||||
833 | # Entry is fine and found | ||||||
834 | else { | ||||||
835 | 7 | 33 | $topics{$href} = 1; | ||||
836 | }; | ||||||
837 | 7 | 54 | }); | ||||
838 | |||||||
839 | 7 | 104 | return [ sort keys %topics ]; | ||||
840 | }; | ||||||
841 | |||||||
842 | |||||||
843 | # Check signature | ||||||
844 | sub _check_signature { | ||||||
845 | 3 | 3 | 7 | my ($c, $secret) = @_; | |||
846 | |||||||
847 | 3 | 11 | my $req = $c->req; | ||||
848 | |||||||
849 | # Get signature | ||||||
850 | 3 | 43 | my $signature = $req->headers->header('X-Hub-Signature'); | ||||
851 | |||||||
852 | # Signature expected but not given | ||||||
853 | 3 | 100 | 64 | return unless $signature; | |||
854 | |||||||
855 | # Delete signature prefix - don't remind, if it's not there. | ||||||
856 | 2 | 20 | $signature =~ s/^sha1=//i; | ||||
857 | |||||||
858 | # Generate check signature | ||||||
859 | 2 | 9 | my $signature_check = hmac_sha1_sum $req->body, $secret; | ||||
860 | |||||||
861 | # Return true if signature check succeeds | ||||||
862 | 2 | 66 | return secure_compare $signature, $signature_check; | ||||
863 | }; | ||||||
864 | |||||||
865 | |||||||
866 | # Render success | ||||||
867 | sub _render_success { | ||||||
868 | 7 | 7 | 15 | my $c = shift; | |||
869 | 7 | 14 | my $x_hub_on_behalf_of = shift; | ||||
870 | |||||||
871 | # Set X-Hub-On-Behalf-Of header | ||||||
872 | 7 | 50 | 33 | 74 | if ($x_hub_on_behalf_of && | ||
873 | $x_hub_on_behalf_of =~ s/^\s*(\d+)\s*$/$1/) { | ||||||
874 | |||||||
875 | # Set X-Hub-On-Behalf-Of header | ||||||
876 | 7 | 28 | $c->res->headers->header( | ||||
877 | 'X-Hub-On-Behalf-Of' => $x_hub_on_behalf_of | ||||||
878 | ); | ||||||
879 | }; | ||||||
880 | |||||||
881 | # Render success with no content | ||||||
882 | 7 | 346 | return $c->render( | ||||
883 | status => 204, | ||||||
884 | format => 'txt', | ||||||
885 | data => '' | ||||||
886 | ); | ||||||
887 | }; | ||||||
888 | |||||||
889 | |||||||
890 | # Render fail | ||||||
891 | sub _render_fail { | ||||||
892 | 2 | 2 | 6 | my $c = shift; | |||
893 | |||||||
894 | 2 | 5 | my $fail =<<'FAIL'; | ||||
895 | |||||||
896 | |||||||
897 | |||||||
898 | |
||||||
899 | |||||||
900 | |||||||
901 | PubSubHubbub Endpoint |
||||||
902 |
|
||||||
903 | This is an endpoint for the | ||||||
904 | PubSubHubbub protocol | ||||||
905 | |||||||
906 | Your request was not correct. |
||||||
907 | |||||||
908 | |||||||
909 | FAIL | ||||||
910 | |||||||
911 | 2 | 23 | return $c->render( | ||||
912 | data => $fail, | ||||||
913 | status => 400 # bad request | ||||||
914 | ); | ||||||
915 | }; | ||||||
916 | |||||||
917 | |||||||
918 | 1; | ||||||
919 | |||||||
920 | |||||||
921 | __END__ |