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