From 9edfe6ee186759a833d4f9e0f576bae2d49c761f Mon Sep 17 00:00:00 2001 From: Aaron Parecki Date: Mon, 1 May 2017 12:28:58 -0700 Subject: [PATCH] first commit of helper functions for writing a WebSub client --- .gitignore | 3 + CONTRIBUTING.md | 1 + LICENSE | 21 ++++ README.md | 49 ++++++++ composer.json | 29 +++++ src/p3k/WebSub/Client.php | 228 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 331 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/p3k/WebSub/Client.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8622dcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +.DS_Store +composer.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3866598 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +By submitting code to this project, you agree to irrevocably release it under the same license as this project. See README.md for more details. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42310e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Aaron Parecki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4eb856 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# p3k-websub + +## Usage + +### Initialize the client + +```php +$http = new p3k\HTTP('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) p3k-websub/0.1.0 example'); +$client = new p3k\WebSub\Client($http); +``` + +### Discover the hub and self URLs for a topic URL + +```php +// Returns false unless both hub and self were found +$endpoints = $client->discover($topic); + +// $endpoints['hub'] +// $endpoints['self'] +``` + +### Send the subscription request + +```php +$secret = p3k\random_string(32); +$id = p3k\random_string(32); +$callback = 'http://localhost:8080/subscriber.php?id='.$id; + +$subscription = $client->subscribe($endpoints['hub'], $endpoints['self'], $callback, [ + 'lease_seconds' => 300, + 'secret' => $secret +]); +``` + +### Verify the signature + +```php +$signature = $_SERVER['HTTP_X_HUB_SIGNATURE']; +$document = file_get_contents('php://input'); +$valid = p3k\WebSub\Client::verify_signature($document, $signature, $secret); +``` + + +## License + +Copyright 2017 by Aaron Parecki + +Available under the MIT license. + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1b06e99 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "p3k/websub", + "type": "library", + "description": "A library for subscribing to and publishing WebSub feeds", + "keywords": ["websub","pubsubhubbub","pubsub","indieweb","p3k","feed"], + "license": "MIT", + "homepage": "https://github.com/aaronpk/p3k-websub", + "authors": [ + { + "name": "Aaron Parecki", + "homepage": "https://aaronparecki.com" + } + ], + "require": { + "indieweb/link-rel-parser": "0.1.*", + "p3k/http": "0.1.*", + "p3k/utils": "1.*" + }, + "require-dev": { + "predis/predis": "1.*" + }, + "autoload": { + "psr-4": { + "p3k\\WebSub\\": "src/p3k/WebSub" + } + }, + "autoload-dev": { + } +} diff --git a/src/p3k/WebSub/Client.php b/src/p3k/WebSub/Client.php new file mode 100644 index 0000000..4ae963b --- /dev/null +++ b/src/p3k/WebSub/Client.php @@ -0,0 +1,228 @@ +http = $http; + } else { + $this->http = new HTTP('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) p3k-websub/0.1.0'); + } + } + + public function discover($url, $headfirst=true, $verbose=false) { + $hub = false; + $self = false; + $type = 'unknown'; + + $http = [ + 'hub' => [], + 'self' => [], + 'type' => $type, + ]; + $body = [ + 'hub' => [], + 'self' => [], + 'type' => $type, + ]; + + if($headfirst) { + // First make a HEAD request, and if the links are found there, stop. + $topic = $this->http->head($url); + + // Get the values from the Link headers + if(array_key_exists('hub', $topic['rels'])) { + $http['hub'] = $topic['rels']['hub']; + $hub = $http['hub'][0]; + } + if(array_key_exists('self', $topic['rels'])) { + $http['self'] = $topic['rels']['self']; + $self = $http['self'][0]; + } + + $content_type = ''; + if(array_key_exists('Content-Type', $topic['headers'])) { + $content_type = $topic['headers']['Content-Type']; + if(is_array($content_type)) + $content_type = $content_type[count($content_type)-1]; + + if(strpos($content_type, 'text/html') !== false) { + $type = $http['type'] = 'html'; + } else if(strpos($content_type, 'xml') !== false) { + if(strpos('rss', $content_type) !== false) { + $type = $http['type'] = 'rss'; + } else if(strpos($content_type, 'atom') !== false) { + $type = $http['type'] = 'atom'; + } + } + } + } + + if(!$hub || !$self) { + // If we're missing hub or self, now make a GET request + $topic = $this->http->get($url); + + $content_type = ''; + if(array_key_exists('Content-Type', $topic['headers'])) { + $content_type = $topic['headers']['Content-Type']; + if(is_array($content_type)) + $content_type = $content_type[count($content_type)-1]; + + // Get the values from the Link headers + if(array_key_exists('hub', $topic['rels'])) { + $http['hub'] = $topic['rels']['hub']; + } + if(array_key_exists('self', $topic['rels'])) { + $http['self'] = $topic['rels']['self']; + } + + if(preg_match('|text/html|', $content_type)) { + $type = $body['type'] = 'html'; + + $dom = p3k\html_to_dom_document($topic['body']); + $xpath = new DOMXPath($dom); + + foreach($xpath->query('*/link[@href]') as $link) { + $rel = $link->getAttribute('rel'); + $url = $link->getAttribute('href'); + if($rel == 'hub') { + $body['hub'][] = $url; + } else if($rel == 'self') { + $body['self'][] = $url; + } + } + + } else if(preg_match('|xml|', $content_type)) { + $dom = p3k\xml_to_dom_document($topic['body']); + $xpath = new DOMXPath($dom); + $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom'); + + if($xpath->query('/rss')->length) { + $type = $body['type'] = 'rss'; + } elseif($xpath->query('/atom:feed')->length) { + $type = $body['type'] = 'atom'; + } + + // Look for atom link elements in the feed + foreach($xpath->query('/atom:feed/atom:link[@href]') as $link) { + $rel = $link->getAttribute('rel'); + $url = $link->getAttribute('href'); + if($rel == 'hub') { + $body['hub'][] = $url; + } else if($rel == 'self') { + $body['self'][] = $url; + } + } + + // Some RSS feeds include the link element as an atom attribute + foreach($xpath->query('/rss/channel/atom:link[@href]') as $link) { + $rel = $link->getAttribute('rel'); + $url = $link->getAttribute('href'); + if($rel == 'hub') { + $body['hub'][] = $url; + } else if($rel == 'self') { + $body['self'][] = $url; + } + } + } + } + } + + // Prioritize the HTTP headers + if($http['hub']) { + $hub = $http['hub'][0]; + $hub_source = 'http'; + } + elseif($body['hub']) { + $hub = $body['hub'][0]; + $hub_source = 'body'; + } else { + $hub_source = false; + } + + if($http['self']) { + $self = $http['self'][0]; + $self_source = 'http'; + } + elseif($body['self']) { + $self = $body['self'][0]; + $self_source = 'body'; + } else { + $self_source = false; + } + + if(!($hub && $self)) { + return false; + } + + $response = [ + 'hub' => $hub, + 'hub_source' => $hub_source, + 'self' => $self, + 'self_source' => $self_source, + 'type' => $type, + ]; + + if($verbose) { + $response['details'] = [ + 'http' => $http, + 'body' => $body + ]; + } + + return $response; + } + + public function subscribe($hub, $topic, $callback, $options=[]) { + $params = [ + 'hub.mode' => 'subscribe', + 'hub.topic' => $topic, + 'hub.callback' => $callback, + ]; + if(isset($options['lease_seconds'])) { + $params['hub.lease_seconds'] = $options['lease_seconds']; + } + if(isset($options['secret'])) { + $params['hub.secret'] = $options['secret']; + } + $response = $this->http->post($hub, $params); + + // TODO: Check for HTTP 307/308 and subscribe at the new location + + return $response; + } + + public function unsubscribe($hub, $topic, $callback) { + $params = [ + 'hub.mode' => 'unsubscribe', + 'hub.topic' => $topic, + 'hub.callback' => $callback, + ]; + $response = $this->http->post($hub, $params); + + // TODO: Check for HTTP 307/308 and unsubscribe at the new location + + return $response; + } + + public static function verify_signature($body, $signature_header, $secret) { + if($signature_header && is_string($signature_header) + && preg_match('/(sha(?:1|256|384|512))=(.+)/', $signature_header, $match)) { + $alg = $match[1]; + $sig = $match[2]; + $expected_signature = hash_hmac($alg, $body, $secret); + return $sig == $expected_signature; + } else { + return false; + } + } + +} +