| 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__ |