]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/FeedSub.php
OStatus: sub/unsub notifications working again. Fixed up autodetection of feed info...
[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 $feeduri;
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*/ '0',
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             throw new ServerException("Attempting to start PuSH subscription to feed in 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      *
259      * @return bool true on success, false on failure
260      * @throws ServerException if feed state is not valid
261      */
262     public function unsubscribe() {
263         if ($this->sub_state != 'active') {
264             throw new ServerException("Attempting to end PuSH subscription to feed in state $this->sub_state");
265         }
266         if (empty($this->huburi)) {
267             if (common_config('feedsub', 'nohub')) {
268                 // Fake it! We're just testing remote feeds w/o hubs.
269                 return true;
270             } else {
271                 throw new ServerException("Attempting to end PuSH subscription for feed with no hub");
272             }
273         }
274
275         return $this->doSubscribe('unsubscribe');
276     }
277
278     protected function doSubscribe($mode)
279     {
280         $orig = clone($this);
281         $this->verify_token = common_good_rand(16);
282         if ($mode == 'subscribe') {
283             $this->secret = common_good_rand(32);
284         }
285         $this->sub_state = $mode;
286         $this->update($orig);
287         unset($orig);
288
289         try {
290             $callback = common_local_url('pushcallback', array('feed' => $this->id));
291             $headers = array('Content-Type: application/x-www-form-urlencoded');
292             $post = array('hub.mode' => $mode,
293                           'hub.callback' => $callback,
294                           'hub.verify' => 'async',
295                           'hub.verify_token' => $this->verify_token,
296                           'hub.secret' => $this->secret,
297                           //'hub.lease_seconds' => 0,
298                           'hub.topic' => $this->uri);
299             $client = new HTTPClient();
300             $response = $client->post($this->huburi, $headers, $post);
301             $status = $response->getStatus();
302             if ($status == 202) {
303                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
304                 return true;
305             } else if ($status == 204) {
306                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
307                 return true;
308             } else if ($status >= 200 && $status < 300) {
309                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
310                 return false;
311             } else {
312                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
313                 return false;
314             }
315         } catch (Exception $e) {
316             // wtf!
317             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->uri");
318
319             $orig = clone($this);
320             $this->verify_token = null;
321             $this->sub_state = null;
322             $this->update($orig);
323             unset($orig);
324
325             return false;
326         }
327     }
328
329     /**
330      * Save PuSH subscription confirmation.
331      * Sets approximate lease start and end times and finalizes state.
332      *
333      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
334      */
335     public function confirmSubscribe($lease_seconds=0)
336     {
337         $original = clone($this);
338
339         $this->sub_state = 'active';
340         $this->sub_start = common_sql_date(time());
341         if ($lease_seconds > 0) {
342             $this->sub_end = common_sql_date(time() + $lease_seconds);
343         } else {
344             $this->sub_end = null;
345         }
346         $this->lastupdate = common_sql_now();
347
348         return $this->update($original);
349     }
350
351     /**
352      * Save PuSH unsubscription confirmation.
353      * Wipes active PuSH sub info and resets state.
354      */
355     public function confirmUnsubscribe()
356     {
357         $original = clone($this);
358
359         // @fixme these should all be null, but DB_DataObject doesn't save null values...?????
360         $this->verify_token = '';
361         $this->secret = '';
362         $this->sub_state = '';
363         $this->sub_start = '';
364         $this->sub_end = '';
365         $this->lastupdate = common_sql_now();
366
367         return $this->update($original);
368     }
369
370     /**
371      * Accept updates from a PuSH feed. If validated, this object and the
372      * feed (as a DOMDocument) will be passed to the StartFeedSubHandleFeed
373      * and EndFeedSubHandleFeed events for processing.
374      *
375      * @param string $post source of Atom or RSS feed
376      * @param string $hmac X-Hub-Signature header, if present
377      */
378     public function receive($post, $hmac)
379     {
380         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->uri\"! $hmac $post");
381
382         if ($this->sub_state != 'active') {
383             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->uri (in state '$this->sub_state')");
384             return;
385         }
386
387         if ($post === '') {
388             common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
389             return;
390         }
391
392         if (!$this->validatePushSig($post, $hmac)) {
393             // Per spec we silently drop input with a bad sig,
394             // while reporting receipt to the server.
395             return;
396         }
397
398         $feed = new DOMDocument();
399         if (!$feed->loadXML($post)) {
400             // @fixme might help to include the err message
401             common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
402             return;
403         }
404
405         Event::handle('StartFeedSubReceive', array($this, $feed));
406         Event::handle('EndFeedSubReceive', array($this, $feed));
407     }
408
409     /**
410      * Validate the given Atom chunk and HMAC signature against our
411      * shared secret that was set up at subscription time.
412      *
413      * If we don't have a shared secret, there should be no signature.
414      * If we we do, our the calculated HMAC should match theirs.
415      *
416      * @param string $post raw XML source as POSTed to us
417      * @param string $hmac X-Hub-Signature HTTP header value, or empty
418      * @return boolean true for a match
419      */
420     protected function validatePushSig($post, $hmac)
421     {
422         if ($this->secret) {
423             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
424                 $their_hmac = strtolower($matches[1]);
425                 $our_hmac = hash_hmac('sha1', $post, $this->secret);
426                 if ($their_hmac === $our_hmac) {
427                     return true;
428                 }
429                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
430             } else {
431                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
432             }
433         } else {
434             if (empty($hmac)) {
435                 return true;
436             } else {
437                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
438             }
439         }
440         return false;
441     }
442
443 }