]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/FeedSub.php
Merge branch 'master' of git@gitorious.org:statusnet/mainline into testing
[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      *
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             common_log(LOG_WARNING, "Attempting to (re)end PuSH subscription to $this->uri in unexpected 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' => 'sync',
295                           'hub.verify_token' => $this->verify_token,
296                           'hub.secret' => $this->secret,
297                           'hub.topic' => $this->uri);
298             $client = new HTTPClient();
299             $response = $client->post($this->huburi, $headers, $post);
300             $status = $response->getStatus();
301             if ($status == 202) {
302                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
303                 return true;
304             } else if ($status == 204) {
305                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
306                 return true;
307             } else if ($status >= 200 && $status < 300) {
308                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
309                 return false;
310             } else {
311                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
312                 return false;
313             }
314         } catch (Exception $e) {
315             // wtf!
316             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->uri");
317
318             $orig = clone($this);
319             $this->verify_token = '';
320             $this->sub_state = 'inactive';
321             $this->update($orig);
322             unset($orig);
323
324             return false;
325         }
326     }
327
328     /**
329      * Save PuSH subscription confirmation.
330      * Sets approximate lease start and end times and finalizes state.
331      *
332      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
333      */
334     public function confirmSubscribe($lease_seconds=0)
335     {
336         $original = clone($this);
337
338         $this->sub_state = 'active';
339         $this->sub_start = common_sql_date(time());
340         if ($lease_seconds > 0) {
341             $this->sub_end = common_sql_date(time() + $lease_seconds);
342         } else {
343             $this->sub_end = null;
344         }
345         $this->modified = common_sql_now();
346
347         return $this->update($original);
348     }
349
350     /**
351      * Save PuSH unsubscription confirmation.
352      * Wipes active PuSH sub info and resets state.
353      */
354     public function confirmUnsubscribe()
355     {
356         $original = clone($this);
357
358         // @fixme these should all be null, but DB_DataObject doesn't save null values...?????
359         $this->verify_token = '';
360         $this->secret = '';
361         $this->sub_state = '';
362         $this->sub_start = '';
363         $this->sub_end = '';
364         $this->modified = common_sql_now();
365
366         return $this->update($original);
367     }
368
369     /**
370      * Accept updates from a PuSH feed. If validated, this object and the
371      * feed (as a DOMDocument) will be passed to the StartFeedSubHandleFeed
372      * and EndFeedSubHandleFeed events for processing.
373      *
374      * Not guaranteed to be running in an immediate POST context; may be run
375      * from a queue handler.
376      *
377      * Side effects: the feedsub record's lastupdate field will be updated
378      * to the current time (not published time) if we got a legit update.
379      *
380      * @param string $post source of Atom or RSS feed
381      * @param string $hmac X-Hub-Signature header, if present
382      */
383     public function receive($post, $hmac)
384     {
385         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->uri\"! $hmac $post");
386
387         if ($this->sub_state != 'active') {
388             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->uri (in state '$this->sub_state')");
389             return;
390         }
391
392         if ($post === '') {
393             common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
394             return;
395         }
396
397         if (!$this->validatePushSig($post, $hmac)) {
398             // Per spec we silently drop input with a bad sig,
399             // while reporting receipt to the server.
400             return;
401         }
402
403         $feed = new DOMDocument();
404         if (!$feed->loadXML($post)) {
405             // @fixme might help to include the err message
406             common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
407             return;
408         }
409
410         $orig = clone($this);
411         $this->last_update = common_sql_now();
412         $this->update($orig);
413
414         Event::handle('StartFeedSubReceive', array($this, $feed));
415         Event::handle('EndFeedSubReceive', array($this, $feed));
416     }
417
418     /**
419      * Validate the given Atom chunk and HMAC signature against our
420      * shared secret that was set up at subscription time.
421      *
422      * If we don't have a shared secret, there should be no signature.
423      * If we we do, our the calculated HMAC should match theirs.
424      *
425      * @param string $post raw XML source as POSTed to us
426      * @param string $hmac X-Hub-Signature HTTP header value, or empty
427      * @return boolean true for a match
428      */
429     protected function validatePushSig($post, $hmac)
430     {
431         if ($this->secret) {
432             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
433                 $their_hmac = strtolower($matches[1]);
434                 $our_hmac = hash_hmac('sha1', $post, $this->secret);
435                 if ($their_hmac === $our_hmac) {
436                     return true;
437                 }
438                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
439             } else {
440                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
441             }
442         } else {
443             if (empty($hmac)) {
444                 return true;
445             } else {
446                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
447             }
448         }
449         return false;
450     }
451
452 }
453