]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/FeedSub.php
Update huburi for FeedSub if PuSH signature is invalid
[quix0rs-gnu-social.git] / plugins / OStatus / classes / FeedSub.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 if (!defined('GNUSOCIAL')) { exit(1); }
21
22 /**
23  * @package OStatusPlugin
24  * @maintainer Brion Vibber <brion@status.net>
25  */
26
27 /*
28 PuSH subscription flow:
29
30     $profile->subscribe()
31         sends a sub request to the hub...
32
33     main/push/callback
34         hub sends confirmation back to us via GET
35         We verify the request, then echo back the challenge.
36         On our end, we save the time we subscribed and the lease expiration
37
38     main/push/callback
39         hub sends us updates via POST
40
41 */
42
43 /**
44  * FeedSub handles low-level PubHubSubbub (PuSH) subscriptions.
45  * Higher-level behavior building OStatus stuff on top is handled
46  * under Ostatus_profile.
47  */
48 class FeedSub extends Managed_DataObject
49 {
50     public $__table = 'feedsub';
51
52     public $id;
53     public $uri;    // varchar(191)   not 255 because utf8mb4 takes more space
54
55     // PuSH subscription data
56     public $huburi;
57     public $secret;
58     public $sub_state; // subscribe, active, unsubscribe, inactive, nohub
59     public $sub_start;
60     public $sub_end;
61     public $last_update;
62
63     public $created;
64     public $modified;
65
66     public static function schemaDef()
67     {
68         return array(
69             'fields' => array(
70                 'id' => array('type' => 'serial', 'not null' => true, 'description' => 'FeedSub local unique id'),
71                 'uri' => array('type' => 'varchar', 'not null' => true, 'length' => 191, 'description' => 'FeedSub uri'),
72                 'huburi' => array('type' => 'text', 'description' => 'FeedSub hub-uri'),
73                 'secret' => array('type' => 'text', 'description' => 'FeedSub stored secret'),
74                 'sub_state' => array('type' => 'enum("subscribe","active","unsubscribe","inactive","nohub")', 'not null' => true, 'description' => 'subscription state'),
75                 'sub_start' => array('type' => 'datetime', 'description' => 'subscription start'),
76                 'sub_end' => array('type' => 'datetime', 'description' => 'subscription end'),
77                 'last_update' => array('type' => 'datetime', 'description' => 'when this record was last updated'),
78                 'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
79                 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
80             ),
81             'primary key' => array('id'),
82             'unique keys' => array(
83                 'feedsub_uri_key' => array('uri'),
84             ),
85         );
86     }
87
88     /**
89      * Get the feed uri (http/https)
90      */
91     public function getUri()
92     {
93         if (empty($this->uri)) {
94             throw new NoUriException($this);
95         }
96         return $this->uri;
97     }
98
99     function getLeaseRemaining()
100     {
101         if (empty($this->sub_end)) {
102             return null;
103         }
104         return strtotime($this->sub_end) - time();
105     }
106
107     /**
108      * Do we have a hub? Then we are a PuSH feed.
109      * https://en.wikipedia.org/wiki/PubSubHubbub
110      *
111      * If huburi is empty, then doublecheck that we are not using
112      * a fallback hub. If there is a fallback hub, it is only if the
113      * sub_state is "nohub" that we assume it's not a PuSH feed.
114      */
115     public function isPuSH()
116     {
117         if (empty($this->huburi)
118                 && (!common_config('feedsub', 'fallback_hub')
119                     || $this->sub_state === 'nohub')) {
120                 // Here we have no huburi set. Also, either there is no 
121                 // fallback hub configured or sub_state is "nohub".
122             return false;
123         }
124         return true;
125     }
126
127     /**
128      * Fetch the StatusNet-side profile for this feed
129      * @return Profile
130      */
131     public function localProfile()
132     {
133         if ($this->profile_id) {
134             return Profile::getKV('id', $this->profile_id);
135         }
136         return null;
137     }
138
139     /**
140      * Fetch the StatusNet-side profile for this feed
141      * @return Profile
142      */
143     public function localGroup()
144     {
145         if ($this->group_id) {
146             return User_group::getKV('id', $this->group_id);
147         }
148         return null;
149     }
150
151     /**
152      * @param string $feeduri
153      * @return FeedSub
154      * @throws FeedSubException if feed is invalid or lacks PuSH setup
155      */
156     public static function ensureFeed($feeduri)
157     {
158         $feedsub = self::getKV('uri', $feeduri);
159         if ($feedsub instanceof FeedSub) {
160             if (!empty($feedsub->huburi)) {
161                 // If there is already a huburi we don't
162                 // rediscover it on ensureFeed, call
163                 // ensureHub to do that (compare ->modified
164                 // to see if it might be time to do it).
165                 return $feedsub;
166             }
167             if ($feedsub->sub_state !== 'inactive') {
168                 throw new ServerException('Can only ensure WebSub hub for inactive (unsubscribed) feeds.');
169             }
170             // If huburi is empty we continue with ensureHub
171         } else {
172             // If we don't have that local feed URI
173             // stored then we create a new DB object.
174             $feedsub = new FeedSub();
175             $feedsub->uri = $feeduri;
176             $feedsub->sub_state = 'inactive';
177         }
178
179         try {
180             // discover the hub uri
181             $feedsub->ensureHub();
182
183         } catch (FeedSubNoHubException $e) {
184             // Only throw this exception if we can't handle huburi-less feeds
185             // (i.e. we have a fallback hub or we can do feed polling (nohub)
186             if (!common_config('feedsub', 'fallback_hub') && !common_config('feedsub', 'nohub')) {
187                 throw $e;
188             }
189         }
190
191         if (empty($feedsub->id)) {
192             // if $feedsub doesn't have an id we'll insert it into the db here
193             $feedsub->created = common_sql_now();
194             $feedsub->modified = common_sql_now();
195             $result = $feedsub->insert();
196             if ($result === false) {
197                 throw new FeedDBException($feedsub);
198             }
199         }
200
201         return $feedsub;
202     }
203
204     /**
205      * ensureHub will only do $this->update if !empty($this->id)
206      * because otherwise the object has not been created yet.
207      */
208     public function ensureHub()
209     {
210         if ($this->sub_state !== 'inactive') {
211             common_log(LOG_INFO, sprintf('Running hub discovery a possibly active feed in %s state for URI %s', _ve($this->sub_state), _ve($this->uri)));
212         }
213
214         $discover = new FeedDiscovery();
215         $discover->discoverFromFeedURL($this->uri);
216
217         $huburi = $discover->getHubLink();
218         if (empty($huburi)) {
219             // Will be caught and treated with if statements in regards to
220             // fallback hub and feed polling (nohub) configuration.
221             throw new FeedSubNoHubException();
222         }
223
224         $orig = !empty($this->id) ? clone($this) : null;
225
226         if (!empty($this->huburi) && $this->huburi !== $huburi) {
227             // There was a huburi already and now we're replacing it,
228             // so we have to set a new secret because otherwise we're
229             // possibly vulnerable to attack from the previous hub.
230
231             // ...but as I understand it this is done in $this->doSubscribe()
232             // which is called from $this->subscribe() (which in turn is
233             // called from $this->renew())
234         }
235         $this->huburi = $huburi;
236
237         if (!empty($this->id)) {
238             $result = $this->update($orig);
239             if ($result === false) {
240                 // TODO: Get a DB exception class going...
241                 common_debug('Database update failed for FeedSub id=='._ve($this->id).' with new huburi: '._ve($this->huburi));
242                 throw new ServerException('Database update failed for FeedSub.');
243             }
244             return $result;
245         }
246
247         return null;    // we haven't done anything with the database
248     }
249
250     /**
251      * Send a subscription request to the hub for this feed.
252      * The hub will later send us a confirmation POST to /main/push/callback.
253      *
254      * @return void
255      * @throws ServerException if feed state is not valid
256      */
257     public function subscribe()
258     {
259         if ($this->sub_state && $this->sub_state != 'inactive') {
260             common_log(LOG_WARNING, sprintf('Attempting to (re)start PuSH subscription to %s in unexpected state %s', $this->getUri(), $this->sub_state));
261         }
262
263         if (!Event::handle('FeedSubscribe', array($this))) {
264             // A plugin handled it
265             return;
266         }
267
268         if (empty($this->huburi)) {
269             if (common_config('feedsub', 'fallback_hub')) {
270                 // No native hub on this feed?
271                 // Use our fallback hub, which handles polling on our behalf.
272             } else if (common_config('feedsub', 'nohub')) {
273                 // For this to actually work, we'll need some polling mechanism.
274                 // The FeedPoller plugin should take care of it.
275                 return;
276             } else {
277                 // TRANS: Server exception.
278                 throw new ServerException(_m('Attempting to start PuSH subscription for feed with no hub.'));
279             }
280         }
281
282         $this->doSubscribe('subscribe');
283     }
284
285     /**
286      * Send a PuSH unsubscription request to the hub for this feed.
287      * The hub will later send us a confirmation POST to /main/push/callback.
288      * Warning: this will cancel the subscription even if someone else in
289      * the system is using it. Most callers will want garbageCollect() instead,
290      * which confirms there's no uses left.
291      *
292      * @throws ServerException if feed state is not valid
293      */
294     public function unsubscribe() {
295         if ($this->sub_state != 'active') {
296             common_log(LOG_WARNING, sprintf('Attempting to (re)end PuSH subscription to %s in unexpected state %s', $this->getUri(), $this->sub_state));
297         }
298
299         if (!Event::handle('FeedUnsubscribe', array($this))) {
300             // A plugin handled it
301             return;
302         }
303
304         if (empty($this->huburi) && !common_config('feedsub', 'fallback_hub')) {
305             /**
306              * If the huburi is empty and we don't have a fallback hub,
307              * there is nowhere we can send an unsubscribe to.
308              *
309              * A plugin should handle the FeedSub above and set the proper state
310              * if there is no hub. (instead of 'nohub' it should be 'inactive' if
311              * the instance has enabled feed polling for feeds that don't publish
312              * PuSH/WebSub hubs. FeedPoller is a plugin which enables polling.
313              *
314              * Secondly, if we don't have the setting "nohub" enabled (i.e.)
315              * we're ready to poll ourselves, there is something odd with the
316              * database, such as a polling plugin that has been disabled.
317              */
318
319             if (!common_config('feedsub', 'nohub')) {
320                 // TRANS: Server exception.
321                 throw new ServerException(_m('Attempting to end PuSH subscription for feed with no hub.'));
322             }
323
324             return;
325         }
326
327         $this->doSubscribe('unsubscribe');
328     }
329
330     /**
331      * Check if there are any active local uses of this feed, and if not then
332      * make sure it's inactive, unsubscribing if necessary.
333      *
334      * @return boolean true if the subscription is now inactive, false if still active.
335      * @throws NoProfileException in FeedSubSubscriberCount for missing Profile entries
336      * @throws Exception if something goes wrong in unsubscribe() method
337      */
338     public function garbageCollect()
339     {
340         if ($this->sub_state == '' || $this->sub_state == 'inactive') {
341             // No active PuSH subscription, we can just leave it be.
342             return true;
343         }
344
345         // PuSH subscription is either active or in an indeterminate state.
346         // Check if we're out of subscribers, and if so send an unsubscribe.
347         $count = 0;
348         Event::handle('FeedSubSubscriberCount', array($this, &$count));
349
350         if ($count > 0) {
351             common_log(LOG_INFO, __METHOD__ . ': ok, ' . $count . ' user(s) left for ' . $this->getUri());
352             return false;
353         }
354
355         common_log(LOG_INFO, __METHOD__ . ': unsubscribing, no users left for ' . $this->getUri());
356         // Unsubscribe throws various Exceptions on failure
357         $this->unsubscribe();
358
359         return true;
360     }
361
362     static public function renewalCheck()
363     {
364         $fs = new FeedSub();
365         // the "" empty string check is because we historically haven't saved unsubscribed feeds as NULL
366         $fs->whereAdd('sub_end IS NOT NULL AND sub_end!="" AND sub_end < NOW() + INTERVAL 1 day');
367         if (!$fs->find()) { // find can be both false and 0, depending on why nothing was found
368             throw new NoResultException($fs);
369         }
370         return $fs;
371     }
372
373     public function renew()
374     {
375         common_debug('FeedSub is being renewed for uri=='._ve($this->uri).' on huburi=='._ve($this->huburi));
376         $this->subscribe();
377     }
378
379     /**
380      * Setting to subscribe means it is _waiting_ to become active. This
381      * cannot be done in a transaction because there is a chance that the
382      * remote script we're calling (as in the case of PuSHpress) performs
383      * the lookup _while_ we're POSTing data, which means the transaction
384      * never completes (PushcallbackAction gets an 'inactive' state).
385      *
386      * @return boolean true when everything is ok (throws Exception on fail)
387      * @throws Exception on failure, can be HTTPClient's or our own.
388      */
389     protected function doSubscribe($mode)
390     {
391         $orig = clone($this);
392         if ($mode == 'subscribe') {
393             $this->secret = common_random_hexstr(32);
394         }
395         $this->sub_state = $mode;
396         $this->update($orig);
397         unset($orig);
398
399         try {
400             $callback = common_local_url('pushcallback', array('feed' => $this->id));
401             $headers = array('Content-Type: application/x-www-form-urlencoded');
402             $post = array('hub.mode' => $mode,
403                           'hub.callback' => $callback,
404                           'hub.verify' => 'async',  // TODO: deprecated, remove when noone uses PuSH <0.4 (only 'async' method used there)
405                           'hub.verify_token' => 'Deprecated-since-PuSH-0.4', // TODO: rm!
406
407                           'hub.lease_seconds' => 2592000,   // 3600*24*30, request approximately month long lease (may be changed by hub)
408                           'hub.secret' => $this->secret,
409                           'hub.topic' => $this->getUri());
410             $client = new HTTPClient();
411             if ($this->huburi) {
412                 $hub = $this->huburi;
413             } else {
414                 if (common_config('feedsub', 'fallback_hub')) {
415                     $hub = common_config('feedsub', 'fallback_hub');
416                     if (common_config('feedsub', 'hub_user')) {
417                         $u = common_config('feedsub', 'hub_user');
418                         $p = common_config('feedsub', 'hub_pass');
419                         $client->setAuth($u, $p);
420                     }
421                 } else {
422                     throw new FeedSubException('Server could not find a usable PuSH hub.');
423                 }
424             }
425             $response = $client->post($hub, $headers, $post);
426             $status = $response->getStatus();
427             // PuSH specificed response status code
428             if ($status == 202  || $status == 204) {
429                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
430                 return;
431             } else if ($status >= 200 && $status < 300) {
432                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
433             } else {
434                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
435             }
436         } catch (Exception $e) {
437             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub {$this->huburi} subscribing to {$this->getUri()}");
438
439             // Reset the subscription state.
440             $orig = clone($this);
441             $this->sub_state = 'inactive';
442             $this->update($orig);
443
444             // Throw the Exception again.
445             throw $e;
446         }
447         throw new ServerException("{$mode} request failed.");
448     }
449
450     /**
451      * Save PuSH subscription confirmation.
452      * Sets approximate lease start and end times and finalizes state.
453      *
454      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
455      */
456     public function confirmSubscribe($lease_seconds)
457     {
458         $original = clone($this);
459
460         $this->sub_state = 'active';
461         $this->sub_start = common_sql_date(time());
462         if ($lease_seconds > 0) {
463             $this->sub_end = common_sql_date(time() + $lease_seconds);
464         } else {
465             $this->sub_end = null;  // Backwards compatibility to StatusNet (PuSH <0.4 supported permanent subs)
466         }
467         $this->modified = common_sql_now();
468
469         common_debug(__METHOD__ . ': Updating sub state and metadata for '.$this->getUri());
470         return $this->update($original);
471     }
472
473     /**
474      * Save PuSH unsubscription confirmation.
475      * Wipes active PuSH sub info and resets state.
476      */
477     public function confirmUnsubscribe()
478     {
479         $original = clone($this);
480
481         // @fixme these should all be null, but DB_DataObject doesn't save null values...?????
482         $this->secret = '';
483         $this->sub_state = '';
484         $this->sub_start = '';
485         $this->sub_end = '';
486         $this->modified = common_sql_now();
487
488         return $this->update($original);
489     }
490
491     /**
492      * Accept updates from a PuSH feed. If validated, this object and the
493      * feed (as a DOMDocument) will be passed to the StartFeedSubHandleFeed
494      * and EndFeedSubHandleFeed events for processing.
495      *
496      * Not guaranteed to be running in an immediate POST context; may be run
497      * from a queue handler.
498      *
499      * Side effects: the feedsub record's lastupdate field will be updated
500      * to the current time (not published time) if we got a legit update.
501      *
502      * @param string $post source of Atom or RSS feed
503      * @param string $hmac X-Hub-Signature header, if present
504      */
505     public function receive($post, $hmac)
506     {
507         common_log(LOG_INFO, sprintf(__METHOD__.': packet for %s with HMAC %s', _ve($this->getUri()), _ve($hmac)));
508
509         if (!in_array($this->sub_state, array('active', 'nohub'))) {
510             common_log(LOG_ERR, sprintf(__METHOD__.': ignoring PuSH for inactive feed %s (in state %s)', _ve($this->getUri()), _ve($this->sub_state)));
511             return;
512         }
513
514         if ($post === '') {
515             common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
516             return;
517         }
518
519         try {
520             if (!$this->validatePushSig($post, $hmac)) {
521                 // Per spec we silently drop input with a bad sig,
522                 // while reporting receipt to the server.
523                 return;
524             }
525         } catch (FeedSubBadPushSignatureException $e) {
526             // We got a signature, so something could be wrong. Let's check to see if
527             // maybe upstream has switched to another hub. Let's fetch feed and then
528             // compare rel="hub" with $this->huburi
529
530             $old_huburi = $this->huburi;
531             $this->ensureHub();
532             common_debug(sprintf('Feed uri==%s huburi before=%s after=%s', _ve($this->uri), _ve($old_huburi), _ve($this->huburi)));
533
534             if ($old_huburi !== $this->huburi) {
535                 // let's make sure that this new hub knows that we want to subscribe
536                 $this->renew();
537             }
538         }
539
540         $this->receiveFeed($post);
541     }
542
543     /**
544      * All our feed URIs should be URLs.
545      */
546     public function importFeed()
547     {
548         $feed_url = $this->getUri();
549
550         // Fetch the URL
551         try {
552             common_log(LOG_INFO, sprintf('Importing feed backlog from %s', $feed_url));
553             $feed_xml = HTTPClient::quickGet($feed_url, 'application/atom+xml');
554         } catch (Exception $e) {
555             throw new FeedSubException("Could not fetch feed from URL '%s': %s (%d).\n", $feed_url, $e->getMessage(), $e->getCode());
556         }
557
558         return $this->receiveFeed($feed_xml);
559     }
560
561     protected function receiveFeed($feed_xml)
562     {
563         // We're passed the XML for the Atom feed as $feed_xml,
564         // so read it into a DOMDocument and process.
565         $feed = new DOMDocument();
566         if (!$feed->loadXML($feed_xml)) {
567             // @fixme might help to include the err message
568             common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
569             return;
570         }
571
572         $orig = clone($this);
573         $this->last_update = common_sql_now();
574         $this->update($orig);
575
576         Event::handle('StartFeedSubReceive', array($this, $feed));
577         Event::handle('EndFeedSubReceive', array($this, $feed));
578     }
579
580     /**
581      * Validate the given Atom chunk and HMAC signature against our
582      * shared secret that was set up at subscription time.
583      *
584      * If we don't have a shared secret, there should be no signature.
585      * If we do, our calculated HMAC should match theirs.
586      *
587      * @param string $post raw XML source as POSTed to us
588      * @param string $hmac X-Hub-Signature HTTP header value, or empty
589      * @return boolean true for a match
590      */
591     protected function validatePushSig($post, $hmac)
592     {
593         if ($this->secret) {
594             // {3,16} because shortest hash algorithm name is 3 characters (md2,md4,md5) and longest
595             // is currently 11 characters, but we'll leave some margin in the end...
596             if (preg_match('/^([0-9a-zA-Z\-\,]{3,16})=([0-9a-fA-F]+)$/', $hmac, $matches)) {
597                 $hash_algo  = strtolower($matches[1]);
598                 $their_hmac = strtolower($matches[2]);
599                 common_debug(sprintf(__METHOD__ . ': PuSH from feed %s uses HMAC algorithm %s with value: %s', _ve($this->getUri()), _ve($hash_algo), _ve($their_hmac)));
600
601                 if (!in_array($hash_algo, hash_algos())) {
602                     // We can't handle this at all, PHP doesn't recognize the algorithm name ('md5', 'sha1', 'sha256' etc: https://secure.php.net/manual/en/function.hash-algos.php)
603                     common_log(LOG_ERR, sprintf(__METHOD__.': HMAC algorithm %s unsupported, not found in PHP hash_algos()', _ve($hash_algo)));
604                     return false;
605                 } elseif (!is_null(common_config('security', 'hash_algos')) && !in_array($hash_algo, common_config('security', 'hash_algos'))) {
606                     // We _won't_ handle this because there is a list of accepted hash algorithms and this one is not in it.
607                     common_log(LOG_ERR, sprintf(__METHOD__.': Whitelist for HMAC algorithms exist, but %s is not included.', _ve($hash_algo)));
608                     return false;
609                 }
610
611                 $our_hmac = hash_hmac($hash_algo, $post, $this->secret);
612                 if ($their_hmac !== $our_hmac) {
613                     common_log(LOG_ERR, sprintf(__METHOD__.': ignoring PuSH with bad HMAC hash: got %s, expected %s for feed %s from hub %s', _ve($their_hmac), _ve($our_hmac), _ve($this->getUri()), _ve($this->huburi)));
614                     throw FeedSubBadPushSignatureException('Incoming PuSH signature did not match expected HMAC hash.');
615                 }
616                 return true;
617
618             } else {
619                 common_log(LOG_ERR, sprintf(__METHOD__.': ignoring PuSH with bogus HMAC==', _ve($hmac)));
620             }
621         } else {
622             if (empty($hmac)) {
623                 return true;
624             } else {
625                 common_log(LOG_ERR, sprintf(__METHOD__.': ignoring PuSH with unexpected HMAC==%s', _ve($hmac)));
626             }
627         }
628         return false;
629     }
630
631     public function delete($useWhere=false)
632     {
633         try {
634             $oprofile = Ostatus_profile::getKV('feeduri', $this->getUri());
635             if ($oprofile instanceof Ostatus_profile) {
636                 // Check if there's a profile. If not, handle the NoProfileException below
637                 $profile = $oprofile->localProfile();
638             }
639         } catch (NoProfileException $e) {
640             // If the Ostatus_profile has no local Profile bound to it, let's clean it out at the same time
641             $oprofile->delete();
642         } catch (NoUriException $e) {
643             // FeedSub->getUri() can throw a NoUriException, let's just go ahead and delete it
644         }
645         return parent::delete($useWhere);
646     }
647 }