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