]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/FeedSub.php
Merge remote branch 'statusnet/1.0.x' into irc-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 /**
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->getAtomLink('hub');
211         if (!$huburi) {
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', 'nohub')) {
245                 // Fake it! We're just testing remote feeds w/o hubs.
246                 return true;
247             } else {
248                 throw new ServerException("Attempting to start PuSH subscription for feed with no hub");
249             }
250         }
251
252         return $this->doSubscribe('subscribe');
253     }
254
255     /**
256      * Send a PuSH unsubscription request to the hub for this feed.
257      * The hub will later send us a confirmation POST to /main/push/callback.
258      * Warning: this will cancel the subscription even if someone else in
259      * the system is using it. Most callers will want garbageCollect() instead,
260      * which confirms there's no uses left.
261      *
262      * @return bool true on success, false on failure
263      * @throws ServerException if feed state is not valid
264      */
265     public function unsubscribe() {
266         if ($this->sub_state != 'active') {
267             common_log(LOG_WARNING, "Attempting to (re)end PuSH subscription to $this->uri in unexpected state $this->sub_state");
268         }
269         if (empty($this->huburi)) {
270             if (common_config('feedsub', 'nohub')) {
271                 // Fake it! We're just testing remote feeds w/o hubs.
272                 return true;
273             } else {
274                 throw new ServerException("Attempting to end PuSH subscription for feed with no hub");
275             }
276         }
277
278         return $this->doSubscribe('unsubscribe');
279     }
280
281     /**
282      * Check if there are any active local uses of this feed, and if not then
283      * make sure it's inactive, unsubscribing if necessary.
284      *
285      * @return boolean true if the subscription is now inactive, false if still active.
286      */
287     public function garbageCollect()
288     {
289         if ($this->sub_state == '' || $this->sub_state == 'inactive') {
290             // No active PuSH subscription, we can just leave it be.
291             return true;
292         } else {
293             // PuSH subscription is either active or in an indeterminate state.
294             // Check if we're out of subscribers, and if so send an unsubscribe.
295             $count = 0;
296             Event::handle('FeedSubSubscriberCount', array($this, &$count));
297
298             if ($count) {
299                 common_log(LOG_INFO, __METHOD__ . ': ok, ' . $count . ' user(s) left for ' . $this->uri);
300                 return false;
301             } else {
302                 common_log(LOG_INFO, __METHOD__ . ': unsubscribing, no users left for ' . $this->uri);
303                 return $this->unsubscribe();
304             }
305         }
306     }
307
308     protected function doSubscribe($mode)
309     {
310         $orig = clone($this);
311         $this->verify_token = common_good_rand(16);
312         if ($mode == 'subscribe') {
313             $this->secret = common_good_rand(32);
314         }
315         $this->sub_state = $mode;
316         $this->update($orig);
317         unset($orig);
318
319         try {
320             $callback = common_local_url('pushcallback', array('feed' => $this->id));
321             $headers = array('Content-Type: application/x-www-form-urlencoded');
322             $post = array('hub.mode' => $mode,
323                           'hub.callback' => $callback,
324                           'hub.verify' => 'sync',
325                           'hub.verify_token' => $this->verify_token,
326                           'hub.secret' => $this->secret,
327                           'hub.topic' => $this->uri);
328             $client = new HTTPClient();
329             $response = $client->post($this->huburi, $headers, $post);
330             $status = $response->getStatus();
331             if ($status == 202) {
332                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
333                 return true;
334             } else if ($status == 204) {
335                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
336                 return true;
337             } else if ($status >= 200 && $status < 300) {
338                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
339                 return false;
340             } else {
341                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
342                 return false;
343             }
344         } catch (Exception $e) {
345             // wtf!
346             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->uri");
347
348             $orig = clone($this);
349             $this->verify_token = '';
350             $this->sub_state = 'inactive';
351             $this->update($orig);
352             unset($orig);
353
354             return false;
355         }
356     }
357
358     /**
359      * Save PuSH subscription confirmation.
360      * Sets approximate lease start and end times and finalizes state.
361      *
362      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
363      */
364     public function confirmSubscribe($lease_seconds=0)
365     {
366         $original = clone($this);
367
368         $this->sub_state = 'active';
369         $this->sub_start = common_sql_date(time());
370         if ($lease_seconds > 0) {
371             $this->sub_end = common_sql_date(time() + $lease_seconds);
372         } else {
373             $this->sub_end = null;
374         }
375         $this->modified = common_sql_now();
376
377         return $this->update($original);
378     }
379
380     /**
381      * Save PuSH unsubscription confirmation.
382      * Wipes active PuSH sub info and resets state.
383      */
384     public function confirmUnsubscribe()
385     {
386         $original = clone($this);
387
388         // @fixme these should all be null, but DB_DataObject doesn't save null values...?????
389         $this->verify_token = '';
390         $this->secret = '';
391         $this->sub_state = '';
392         $this->sub_start = '';
393         $this->sub_end = '';
394         $this->modified = common_sql_now();
395
396         return $this->update($original);
397     }
398
399     /**
400      * Accept updates from a PuSH feed. If validated, this object and the
401      * feed (as a DOMDocument) will be passed to the StartFeedSubHandleFeed
402      * and EndFeedSubHandleFeed events for processing.
403      *
404      * Not guaranteed to be running in an immediate POST context; may be run
405      * from a queue handler.
406      *
407      * Side effects: the feedsub record's lastupdate field will be updated
408      * to the current time (not published time) if we got a legit update.
409      *
410      * @param string $post source of Atom or RSS feed
411      * @param string $hmac X-Hub-Signature header, if present
412      */
413     public function receive($post, $hmac)
414     {
415         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->uri\"! $hmac $post");
416
417         if ($this->sub_state != 'active') {
418             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->uri (in state '$this->sub_state')");
419             return;
420         }
421
422         if ($post === '') {
423             common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
424             return;
425         }
426
427         if (!$this->validatePushSig($post, $hmac)) {
428             // Per spec we silently drop input with a bad sig,
429             // while reporting receipt to the server.
430             return;
431         }
432
433         $feed = new DOMDocument();
434         if (!$feed->loadXML($post)) {
435             // @fixme might help to include the err message
436             common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
437             return;
438         }
439
440         $orig = clone($this);
441         $this->last_update = common_sql_now();
442         $this->update($orig);
443
444         Event::handle('StartFeedSubReceive', array($this, $feed));
445         Event::handle('EndFeedSubReceive', array($this, $feed));
446     }
447
448     /**
449      * Validate the given Atom chunk and HMAC signature against our
450      * shared secret that was set up at subscription time.
451      *
452      * If we don't have a shared secret, there should be no signature.
453      * If we we do, our the calculated HMAC should match theirs.
454      *
455      * @param string $post raw XML source as POSTed to us
456      * @param string $hmac X-Hub-Signature HTTP header value, or empty
457      * @return boolean true for a match
458      */
459     protected function validatePushSig($post, $hmac)
460     {
461         if ($this->secret) {
462             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
463                 $their_hmac = strtolower($matches[1]);
464                 $our_hmac = hash_hmac('sha1', $post, $this->secret);
465                 if ($their_hmac === $our_hmac) {
466                     return true;
467                 }
468                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
469             } else {
470                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
471             }
472         } else {
473             if (empty($hmac)) {
474                 return true;
475             } else {
476                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
477             }
478         }
479         return false;
480     }
481
482 }
483