]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/FeedSub.php
Merge branch 'master' of gitorious.org:statusnet/mainline
[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 /**
21  * @package OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 /*
26 PuSH subscription flow:
27
28     $profile->subscribe()
29         generate random verification token
30             save to verify_token
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 class FeedDBException extends FeedSubException
44 {
45     public $obj;
46
47     function __construct($obj)
48     {
49         parent::__construct('Database insert failure');
50         $this->obj = $obj;
51     }
52 }
53
54 /**
55  * FeedSub handles low-level PubHubSubbub (PuSH) subscriptions.
56  * Higher-level behavior building OStatus stuff on top is handled
57  * under Ostatus_profile.
58  */
59 class FeedSub extends Memcached_DataObject
60 {
61     public $__table = 'feedsub';
62
63     public $id;
64     public $uri;
65
66     // PuSH subscription data
67     public $huburi;
68     public $secret;
69     public $verify_token;
70     public $sub_state; // subscribe, active, unsubscribe, inactive
71     public $sub_start;
72     public $sub_end;
73     public $last_update;
74
75     public $created;
76     public $modified;
77
78     public /*static*/ function staticGet($k, $v=null)
79     {
80         return parent::staticGet(__CLASS__, $k, $v);
81     }
82
83     /**
84      * return table definition for DB_DataObject
85      *
86      * DB_DataObject needs to know something about the table to manipulate
87      * instances. This method provides all the DB_DataObject needs to know.
88      *
89      * @return array array of column definitions
90      */
91
92     function table()
93     {
94         return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
95                      'uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
96                      'huburi' =>  DB_DATAOBJECT_STR,
97                      'secret' => DB_DATAOBJECT_STR,
98                      'verify_token' => DB_DATAOBJECT_STR,
99                      'sub_state' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
100                      'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
101                      'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
102                      'last_update' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
103                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
104                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
105     }
106
107     static function schemaDef()
108     {
109         return array(new ColumnDef('id', 'integer',
110                                    /*size*/ null,
111                                    /*nullable*/ false,
112                                    /*key*/ 'PRI',
113                                    /*default*/ null,
114                                    /*extra*/ null,
115                                    /*auto_increment*/ true),
116                      new ColumnDef('uri', 'varchar',
117                                    255, false, 'UNI'),
118                      new ColumnDef('huburi', 'text',
119                                    null, true),
120                      new ColumnDef('verify_token', 'text',
121                                    null, true),
122                      new ColumnDef('secret', 'text',
123                                    null, true),
124                      new ColumnDef('sub_state', "enum('subscribe','active','unsubscribe','inactive')",
125                                    null, false),
126                      new ColumnDef('sub_start', 'datetime',
127                                    null, true),
128                      new ColumnDef('sub_end', 'datetime',
129                                    null, true),
130                      new ColumnDef('last_update', 'datetime',
131                                    null, false),
132                      new ColumnDef('created', 'datetime',
133                                    null, false),
134                      new ColumnDef('modified', 'datetime',
135                                    null, false));
136     }
137
138     /**
139      * return key definitions for DB_DataObject
140      *
141      * DB_DataObject needs to know about keys that the table has; this function
142      * defines them.
143      *
144      * @return array key definitions
145      */
146
147     function keys()
148     {
149         return array_keys($this->keyTypes());
150     }
151
152     /**
153      * return key definitions for Memcached_DataObject
154      *
155      * Our caching system uses the same key definitions, but uses a different
156      * method to get them.
157      *
158      * @return array key definitions
159      */
160
161     function keyTypes()
162     {
163         return array('id' => 'K', 'uri' => 'U');
164     }
165
166     function sequenceKey()
167     {
168         return array('id', true, false);
169     }
170
171     /**
172      * Fetch the StatusNet-side profile for this feed
173      * @return Profile
174      */
175     public function localProfile()
176     {
177         if ($this->profile_id) {
178             return Profile::staticGet('id', $this->profile_id);
179         }
180         return null;
181     }
182
183     /**
184      * Fetch the StatusNet-side profile for this feed
185      * @return Profile
186      */
187     public function localGroup()
188     {
189         if ($this->group_id) {
190             return User_group::staticGet('id', $this->group_id);
191         }
192         return null;
193     }
194
195     /**
196      * @param string $feeduri
197      * @return FeedSub
198      * @throws FeedSubException if feed is invalid or lacks PuSH setup
199      */
200     public static function ensureFeed($feeduri)
201     {
202         $current = self::staticGet('uri', $feeduri);
203         if ($current) {
204             return $current;
205         }
206
207         $discover = new FeedDiscovery();
208         $discover->discoverFromFeedURL($feeduri);
209
210         $huburi = $discover->getHubLink();
211         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
212             throw new FeedSubNoHubException();
213         }
214
215         $feedsub = new FeedSub();
216         $feedsub->uri = $feeduri;
217         $feedsub->huburi = $huburi;
218         $feedsub->sub_state = 'inactive';
219
220         $feedsub->created = common_sql_now();
221         $feedsub->modified = common_sql_now();
222
223         $result = $feedsub->insert();
224         if (empty($result)) {
225             throw new FeedDBException($feedsub);
226         }
227
228         return $feedsub;
229     }
230
231     /**
232      * Send a subscription request to the hub for this feed.
233      * The hub will later send us a confirmation POST to /main/push/callback.
234      *
235      * @return bool true on success, false on failure
236      * @throws ServerException if feed state is not valid
237      */
238     public function subscribe($mode='subscribe')
239     {
240         if ($this->sub_state && $this->sub_state != 'inactive') {
241             common_log(LOG_WARNING, "Attempting to (re)start PuSH subscription to $this->uri in unexpected state $this->sub_state");
242         }
243         if (empty($this->huburi)) {
244             if (common_config('feedsub', 'fallback_hub')) {
245                 // No native hub on this feed?
246                 // Use our fallback hub, which handles polling on our behalf.
247             } else if (common_config('feedsub', 'nohub')) {
248                 // Fake it! We're just testing remote feeds w/o hubs.
249                 // We'll never actually get updates in this mode.
250                 return true;
251             } else {
252                 throw new ServerException("Attempting to start PuSH subscription for feed with no hub");
253             }
254         }
255
256         return $this->doSubscribe('subscribe');
257     }
258
259     /**
260      * Send a PuSH unsubscription request to the hub for this feed.
261      * The hub will later send us a confirmation POST to /main/push/callback.
262      * Warning: this will cancel the subscription even if someone else in
263      * the system is using it. Most callers will want garbageCollect() instead,
264      * which confirms there's no uses left.
265      *
266      * @return bool true on success, false on failure
267      * @throws ServerException if feed state is not valid
268      */
269     public function unsubscribe() {
270         if ($this->sub_state != 'active') {
271             common_log(LOG_WARNING, "Attempting to (re)end PuSH subscription to $this->uri in unexpected state $this->sub_state");
272         }
273         if (empty($this->huburi)) {
274             if (common_config('feedsub', 'fallback_hub')) {
275                 // No native hub on this feed?
276                 // Use our fallback hub, which handles polling on our behalf.
277             } else if (common_config('feedsub', 'nohub')) {
278                 // Fake it! We're just testing remote feeds w/o hubs.
279                 // We'll never actually get updates in this mode.
280                 return true;
281             } else {
282                 throw new ServerException("Attempting to end PuSH subscription for feed with no hub");
283             }
284         }
285
286         return $this->doSubscribe('unsubscribe');
287     }
288
289     /**
290      * Check if there are any active local uses of this feed, and if not then
291      * make sure it's inactive, unsubscribing if necessary.
292      *
293      * @return boolean true if the subscription is now inactive, false if still active.
294      */
295     public function garbageCollect()
296     {
297         if ($this->sub_state == '' || $this->sub_state == 'inactive') {
298             // No active PuSH subscription, we can just leave it be.
299             return true;
300         } else {
301             // PuSH subscription is either active or in an indeterminate state.
302             // Check if we're out of subscribers, and if so send an unsubscribe.
303             $count = 0;
304             Event::handle('FeedSubSubscriberCount', array($this, &$count));
305
306             if ($count) {
307                 common_log(LOG_INFO, __METHOD__ . ': ok, ' . $count . ' user(s) left for ' . $this->uri);
308                 return false;
309             } else {
310                 common_log(LOG_INFO, __METHOD__ . ': unsubscribing, no users left for ' . $this->uri);
311                 return $this->unsubscribe();
312             }
313         }
314     }
315
316     protected function doSubscribe($mode)
317     {
318         $orig = clone($this);
319         $this->verify_token = common_good_rand(16);
320         if ($mode == 'subscribe') {
321             $this->secret = common_good_rand(32);
322         }
323         $this->sub_state = $mode;
324         $this->update($orig);
325         unset($orig);
326
327         try {
328             $callback = common_local_url('pushcallback', array('feed' => $this->id));
329             $headers = array('Content-Type: application/x-www-form-urlencoded');
330             $post = array('hub.mode' => $mode,
331                           'hub.callback' => $callback,
332                           'hub.verify' => 'sync',
333                           'hub.verify_token' => $this->verify_token,
334                           'hub.secret' => $this->secret,
335                           'hub.topic' => $this->uri);
336             $client = new HTTPClient();
337             if ($this->huburi) {
338                 $hub = $this->huburi;
339             } else {
340                 if (common_config('feedsub', 'fallback_hub')) {
341                     $hub = common_config('feedsub', 'fallback_hub');
342                     if (common_config('feedsub', 'hub_user')) {
343                         $u = common_config('feedsub', 'hub_user');
344                         $p = common_config('feedsub', 'hub_pass');
345                         $client->setAuth($u, $p);
346                     }
347                 } else {
348                     throw new FeedSubException('WTF?');
349                 }
350             }
351             $response = $client->post($hub, $headers, $post);
352             $status = $response->getStatus();
353             if ($status == 202) {
354                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
355                 return true;
356             } else if ($status == 204) {
357                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
358                 return true;
359             } else if ($status >= 200 && $status < 300) {
360                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
361                 return false;
362             } else {
363                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
364                 return false;
365             }
366         } catch (Exception $e) {
367             // wtf!
368             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->uri");
369
370             $orig = clone($this);
371             $this->verify_token = '';
372             $this->sub_state = 'inactive';
373             $this->update($orig);
374             unset($orig);
375
376             return false;
377         }
378     }
379
380     /**
381      * Save PuSH subscription confirmation.
382      * Sets approximate lease start and end times and finalizes state.
383      *
384      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
385      */
386     public function confirmSubscribe($lease_seconds=0)
387     {
388         $original = clone($this);
389
390         $this->sub_state = 'active';
391         $this->sub_start = common_sql_date(time());
392         if ($lease_seconds > 0) {
393             $this->sub_end = common_sql_date(time() + $lease_seconds);
394         } else {
395             $this->sub_end = null;
396         }
397         $this->modified = common_sql_now();
398
399         return $this->update($original);
400     }
401
402     /**
403      * Save PuSH unsubscription confirmation.
404      * Wipes active PuSH sub info and resets state.
405      */
406     public function confirmUnsubscribe()
407     {
408         $original = clone($this);
409
410         // @fixme these should all be null, but DB_DataObject doesn't save null values...?????
411         $this->verify_token = '';
412         $this->secret = '';
413         $this->sub_state = '';
414         $this->sub_start = '';
415         $this->sub_end = '';
416         $this->modified = common_sql_now();
417
418         return $this->update($original);
419     }
420
421     /**
422      * Accept updates from a PuSH feed. If validated, this object and the
423      * feed (as a DOMDocument) will be passed to the StartFeedSubHandleFeed
424      * and EndFeedSubHandleFeed events for processing.
425      *
426      * Not guaranteed to be running in an immediate POST context; may be run
427      * from a queue handler.
428      *
429      * Side effects: the feedsub record's lastupdate field will be updated
430      * to the current time (not published time) if we got a legit update.
431      *
432      * @param string $post source of Atom or RSS feed
433      * @param string $hmac X-Hub-Signature header, if present
434      */
435     public function receive($post, $hmac)
436     {
437         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->uri\"! $hmac $post");
438
439         if ($this->sub_state != 'active') {
440             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->uri (in state '$this->sub_state')");
441             return;
442         }
443
444         if ($post === '') {
445             common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
446             return;
447         }
448
449         if (!$this->validatePushSig($post, $hmac)) {
450             // Per spec we silently drop input with a bad sig,
451             // while reporting receipt to the server.
452             return;
453         }
454
455         $feed = new DOMDocument();
456         if (!$feed->loadXML($post)) {
457             // @fixme might help to include the err message
458             common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
459             return;
460         }
461
462         $orig = clone($this);
463         $this->last_update = common_sql_now();
464         $this->update($orig);
465
466         Event::handle('StartFeedSubReceive', array($this, $feed));
467         Event::handle('EndFeedSubReceive', array($this, $feed));
468     }
469
470     /**
471      * Validate the given Atom chunk and HMAC signature against our
472      * shared secret that was set up at subscription time.
473      *
474      * If we don't have a shared secret, there should be no signature.
475      * If we we do, our the calculated HMAC should match theirs.
476      *
477      * @param string $post raw XML source as POSTed to us
478      * @param string $hmac X-Hub-Signature HTTP header value, or empty
479      * @return boolean true for a match
480      */
481     protected function validatePushSig($post, $hmac)
482     {
483         if ($this->secret) {
484             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
485                 $their_hmac = strtolower($matches[1]);
486                 $our_hmac = hash_hmac('sha1', $post, $this->secret);
487                 if ($their_hmac === $our_hmac) {
488                     return true;
489                 }
490                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
491             } else {
492                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
493             }
494         } else {
495             if (empty($hmac)) {
496                 return true;
497             } else {
498                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
499             }
500         }
501         return false;
502     }
503
504 }
505