]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Feedinfo.php
OStatus: garbage collect unused PuSH subscriptions when the last local subscriber...
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Feedinfo.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 FeedSubPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 /*
26 PuSH subscription flow:
27
28     $feedinfo->subscribe()
29         generate random verification token
30             save to verify_token
31         sends a sub request to the hub...
32     
33     feedsub/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     feedsub/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 class Feedinfo extends Memcached_DataObject
55 {
56     public $__table = 'feedinfo';
57
58     public $id;
59     public $profile_id;
60
61     public $feeduri;
62     public $homeuri;
63     public $huburi;
64
65     // PuSH subscription data
66     public $secret;
67     public $verify_token;
68     public $sub_start;
69     public $sub_end;
70
71     public $created;
72     public $lastupdate;
73
74
75     public /*static*/ function staticGet($k, $v=null)
76     {
77         return parent::staticGet(__CLASS__, $k, $v);
78     }
79
80     /**
81      * return table definition for DB_DataObject
82      *
83      * DB_DataObject needs to know something about the table to manipulate
84      * instances. This method provides all the DB_DataObject needs to know.
85      *
86      * @return array array of column definitions
87      */
88
89     function table()
90     {
91         return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
92                      'profile_id' => DB_DATAOBJECT_INT,
93                      'group_id' => DB_DATAOBJECT_INT,
94                      'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
95                      'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
96                      'huburi' =>  DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
97                      'secret' => DB_DATAOBJECT_STR,
98                      'verify_token' => DB_DATAOBJECT_STR,
99                      'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
100                      'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
101                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
102                      'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
103     }
104     
105     static function schemaDef()
106     {
107         return array(new ColumnDef('id', 'integer',
108                                    /*size*/ null,
109                                    /*nullable*/ false,
110                                    /*key*/ 'PRI',
111                                    /*default*/ '0',
112                                    /*extra*/ null,
113                                    /*auto_increment*/ true),
114                      new ColumnDef('profile_id', 'integer',
115                                    null, true, 'UNI'),
116                      new ColumnDef('group_id', 'integer',
117                                    null, true, 'UNI'),
118                      new ColumnDef('feeduri', 'varchar',
119                                    255, false, 'UNI'),
120                      new ColumnDef('homeuri', 'varchar',
121                                    255, false),
122                      new ColumnDef('huburi', 'varchar',
123                                    255, false),
124                      new ColumnDef('verify_token', 'varchar',
125                                    32, true),
126                      new ColumnDef('secret', 'varchar',
127                                    64, true),
128                      new ColumnDef('sub_start', 'datetime',
129                                    null, true),
130                      new ColumnDef('sub_end', 'datetime',
131                                    null, true),
132                      new ColumnDef('created', 'datetime',
133                                    null, false),
134                      new ColumnDef('lastupdate', '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', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => '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 getProfile()
176     {
177         return Profile::staticGet('id', $this->profile_id);
178     }
179
180     /**
181      * @param FeedMunger $munger
182      * @param boolean $isGroup is this a group record?
183      * @return Feedinfo
184      */
185     public static function ensureProfile($munger)
186     {
187         $feedinfo = $munger->feedinfo();
188
189         $current = self::staticGet('feeduri', $feedinfo->feeduri);
190         if ($current) {
191             // @fixme we should probably update info as necessary
192             return $current;
193         }
194
195         $feedinfo->query('BEGIN');
196
197         // Awful hack! Awful hack!
198         $feedinfo->verify = common_good_rand(16);
199         $feedinfo->secret = common_good_rand(32);
200
201         try {
202             $profile = $munger->profile();
203             $result = $profile->insert();
204             if (empty($result)) {
205                 throw new FeedDBException($profile);
206             }
207
208             $avatar = $munger->getAvatar();
209             if ($avatar) {
210                 // @fixme this should be better encapsulated
211                 // ripped from oauthstore.php (for old OMB client)
212                 $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
213                 copy($avatar, $temp_filename);
214                 $imagefile = new ImageFile($profile->id, $temp_filename);
215                 $filename = Avatar::filename($profile->id,
216                                              image_type_to_extension($imagefile->type),
217                                              null,
218                                              common_timestamp());
219                 rename($temp_filename, Avatar::path($filename));
220                 $profile->setOriginal($filename);
221             }
222
223             $feedinfo->profile_id = $profile->id;
224             if ($feedinfo->isGroup()) {
225                 $group = new User_group();
226                 $group->nickname = $profile->nickname . '@remote'; // @fixme
227                 $group->fullname = $profile->fullname;
228                 $group->homepage = $profile->homepage;
229                 $group->location = $profile->location;
230                 $group->created = $profile->created;
231                 $group->insert();
232
233                 if ($avatar) {
234                     $group->setOriginal($filename);
235                 }
236
237                 $feedinfo->group_id = $group->id;
238             }
239
240             $result = $feedinfo->insert();
241             if (empty($result)) {
242                 throw new FeedDBException($feedinfo);
243             }
244
245             $feedinfo->query('COMMIT');
246         } catch (FeedDBException $e) {
247             common_log_db_error($e->obj, 'INSERT', __FILE__);
248             $feedinfo->query('ROLLBACK');
249             return false;
250         }
251         return $feedinfo;
252     }
253
254     /**
255      * Damn dirty hack!
256      */
257     function isGroup()
258     {
259         return (strpos($this->feeduri, '/groups/') !== false);
260     }
261
262     /**
263      * Send a subscription request to the hub for this feed.
264      * The hub will later send us a confirmation POST to /main/push/callback.
265      *
266      * @return bool true on success, false on failure
267      */
268     public function subscribe($mode='subscribe')
269     {
270         if (common_config('feedsub', 'nohub')) {
271             // Fake it! We're just testing remote feeds w/o hubs.
272             return true;
273         }
274         // @fixme use the verification token
275         #$token = md5(mt_rand() . ':' . $this->feeduri);
276         #$this->verify_token = $token;
277         #$this->update(); // @fixme
278         try {
279             $callback = common_local_url('pushcallback', array('feed' => $this->id));
280             $headers = array('Content-Type: application/x-www-form-urlencoded');
281             $post = array('hub.mode' => $mode,
282                           'hub.callback' => $callback,
283                           'hub.verify' => 'async',
284                           'hub.verify_token' => $this->verify_token,
285                           'hub.secret' => $this->secret,
286                           //'hub.lease_seconds' => 0,
287                           'hub.topic' => $this->feeduri);
288             $client = new HTTPClient();
289             $response = $client->post($this->huburi, $headers, $post);
290             $status = $response->getStatus();
291             if ($status == 202) {
292                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
293                 return true;
294             } else if ($status == 204) {
295                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
296                 return true;
297             } else if ($status >= 200 && $status < 300) {
298                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
299                 return false;
300             } else {
301                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
302                 return false;
303             }
304         } catch (Exception $e) {
305             // wtf!
306             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
307             return false;
308         }
309     }
310
311     /**
312      * Send an unsubscription request to the hub for this feed.
313      * The hub will later send us a confirmation POST to /main/push/callback.
314      *
315      * @return bool true on success, false on failure
316      */
317     public function unsubscribe() {
318         return $this->subscribe('unsubscribe');
319     }
320
321     /**
322      * Read and post notices for updates from the feed.
323      * Currently assumes that all items in the feed are new,
324      * coming from a PuSH hub.
325      *
326      * @param string $xml source of Atom or RSS feed
327      * @param string $hmac X-Hub-Signature header, if present
328      */
329     public function postUpdates($xml, $hmac)
330     {
331         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml");
332
333         if ($this->secret) {
334             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
335                 $their_hmac = strtolower($matches[1]);
336                 $our_hmac = hash_hmac('sha1', $xml, $this->secret);
337                 if ($their_hmac !== $our_hmac) {
338                     common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
339                     return;
340                 }
341             } else {
342                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
343                 return;
344             }
345         } else if ($hmac) {
346             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
347             return;
348         }
349
350         require_once "XML/Feed/Parser.php";
351         $feed = new XML_Feed_Parser($xml, false, false, true);
352         $munger = new FeedMunger($feed);
353         
354         $hits = 0;
355         foreach ($feed as $index => $entry) {
356             // @fixme this might sort in wrong order if we get multiple updates
357
358             $notice = $munger->notice($index);
359             $notice->profile_id = $this->profile_id;
360
361             // Double-check for oldies
362             // @fixme this could explode horribly for multiple feeds on a blog. sigh
363             $dupe = new Notice();
364             $dupe->uri = $notice->uri;
365             if ($dupe->find(true)) {
366                 common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}");
367                 continue;
368             }
369
370             // @fixme need to ensure that groups get handled correctly
371             $saved = Notice::saveNew($this->profile_id,
372                                      $notice->content,
373                                      'ostatus',
374                                      array('is_local' => Notice::REMOTE_OMB,
375                                            'uri' => $notice->uri,
376                                            'lat' => $notice->lat,
377                                            'lon' => $notice->lon,
378                                            'location_ns' => $notice->location_ns,
379                                            'location_id' => $notice->location_id));
380
381             /*
382             common_log(LOG_DEBUG, "going to check group delivery...");
383             if ($this->group_id) {
384                 $group = User_group::staticGet($this->group_id);
385                 if ($group) {
386                     common_log(LOG_INFO, __METHOD__ . ": saving to local shadow group $group->id $group->nickname");
387                     $groups = array($group);
388                 } else {
389                     common_log(LOG_INFO, __METHOD__ . ": lost the local shadow group?");
390                 }
391             } else {
392                 common_log(LOG_INFO, __METHOD__ . ": no local shadow groups");
393                 $groups = array();
394             }
395             common_log(LOG_DEBUG, "going to add to inboxes...");
396             $notice->addToInboxes($groups, array());
397             common_log(LOG_DEBUG, "added to inboxes.");
398             */
399
400             $hits++;
401         }
402         if ($hits == 0) {
403             common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml");
404         }
405     }
406 }