]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
f7bbcd02869393abb2cbd55e5794e50b281371fe
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.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     $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 class Ostatus_profile extends Memcached_DataObject
55 {
56     public $__table = 'ostatus_profile';
57
58     public $id;
59     public $profile_id;
60     public $group_id;
61
62     public $feeduri;
63     public $homeuri;
64
65     // PuSH subscription data
66     public $huburi;
67     public $secret;
68     public $verify_token;
69     public $sub_state; // subscribe, active, unsubscribe
70     public $sub_start;
71     public $sub_end;
72
73     public $salmonuri;
74
75     public $created;
76     public $lastupdate;
77
78
79     public /*static*/ function staticGet($k, $v=null)
80     {
81         return parent::staticGet(__CLASS__, $k, $v);
82     }
83
84     /**
85      * return table definition for DB_DataObject
86      *
87      * DB_DataObject needs to know something about the table to manipulate
88      * instances. This method provides all the DB_DataObject needs to know.
89      *
90      * @return array array of column definitions
91      */
92
93     function table()
94     {
95         return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
96                      'profile_id' => DB_DATAOBJECT_INT,
97                      'group_id' => DB_DATAOBJECT_INT,
98                      'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
99                      'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
100                      'huburi' =>  DB_DATAOBJECT_STR,
101                      'secret' => DB_DATAOBJECT_STR,
102                      'verify_token' => DB_DATAOBJECT_STR,
103                      'sub_state' => DB_DATAOBJECT_STR,
104                      'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
105                      'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
106                      'salmonuri' =>  DB_DATAOBJECT_STR,
107                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
108                      'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
109     }
110     
111     static function schemaDef()
112     {
113         return array(new ColumnDef('id', 'integer',
114                                    /*size*/ null,
115                                    /*nullable*/ false,
116                                    /*key*/ 'PRI',
117                                    /*default*/ '0',
118                                    /*extra*/ null,
119                                    /*auto_increment*/ true),
120                      new ColumnDef('profile_id', 'integer',
121                                    null, true, 'UNI'),
122                      new ColumnDef('group_id', 'integer',
123                                    null, true, 'UNI'),
124                      new ColumnDef('feeduri', 'varchar',
125                                    255, false, 'UNI'),
126                      new ColumnDef('homeuri', 'varchar',
127                                    255, false),
128                      new ColumnDef('huburi', 'text',
129                                    null, true),
130                      new ColumnDef('verify_token', 'varchar',
131                                    32, true),
132                      new ColumnDef('secret', 'varchar',
133                                    64, true),
134                      new ColumnDef('sub_state', "enum('subscribe','active','unsubscribe')",
135                                    null, true),
136                      new ColumnDef('sub_start', 'datetime',
137                                    null, true),
138                      new ColumnDef('sub_end', 'datetime',
139                                    null, true),
140                      new ColumnDef('salmonuri', 'text',
141                                    null, true),
142                      new ColumnDef('created', 'datetime',
143                                    null, false),
144                      new ColumnDef('lastupdate', 'datetime',
145                                    null, false));
146     }
147
148     /**
149      * return key definitions for DB_DataObject
150      *
151      * DB_DataObject needs to know about keys that the table has; this function
152      * defines them.
153      *
154      * @return array key definitions
155      */
156
157     function keys()
158     {
159         return array_keys($this->keyTypes());
160     }
161
162     /**
163      * return key definitions for Memcached_DataObject
164      *
165      * Our caching system uses the same key definitions, but uses a different
166      * method to get them.
167      *
168      * @return array key definitions
169      */
170
171     function keyTypes()
172     {
173         return array('id' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
174     }
175
176     function sequenceKey()
177     {
178         return array('id', true, false);
179     }
180
181     /**
182      * Fetch the StatusNet-side profile for this feed
183      * @return Profile
184      */
185     public function localProfile()
186     {
187         if ($this->profile_id) {
188             return Profile::staticGet('id', $this->profile_id);
189         }
190         return null;
191     }
192
193     /**
194      * Fetch the StatusNet-side profile for this feed
195      * @return Profile
196      */
197     public function localGroup()
198     {
199         if ($this->group_id) {
200             return User_group::staticGet('id', $this->group_id);
201         }
202         return null;
203     }
204
205     /**
206      * @param FeedMunger $munger
207      * @param boolean $isGroup is this a group record?
208      * @return Ostatus_profile
209      */
210     public static function ensureProfile($munger)
211     {
212         $profile = $munger->ostatusProfile();
213
214         $current = self::staticGet('feeduri', $profile->feeduri);
215         if ($current) {
216             // @fixme we should probably update info as necessary
217             return $current;
218         }
219
220         $profile->query('BEGIN');
221
222         // Awful hack! Awful hack!
223         $profile->verify = common_good_rand(16);
224         $profile->secret = common_good_rand(32);
225
226         try {
227             $local = $munger->profile();
228
229             if ($entity->isGroup()) {
230                 $group = new User_group();
231                 $group->nickname = $local->nickname . '@remote'; // @fixme
232                 $group->fullname = $local->fullname;
233                 $group->homepage = $local->homepage;
234                 $group->location = $local->location;
235                 $group->created = $local->created;
236                 $group->insert();
237                 if (empty($result)) {
238                     throw new FeedDBException($group);
239                 }
240                 $profile->group_id = $group->id;
241             } else {
242                 $result = $local->insert();
243                 if (empty($result)) {
244                     throw new FeedDBException($local);
245                 }
246                 $profile->profile_id = $local->id;
247             }
248
249             $profile->created = sql_common_date();
250             $profile->lastupdate = sql_common_date();
251             $result = $profile->insert();
252             if (empty($result)) {
253                 throw new FeedDBException($profile);
254             }
255
256             $entity->query('COMMIT');
257         } catch (FeedDBException $e) {
258             common_log_db_error($e->obj, 'INSERT', __FILE__);
259             $entity->query('ROLLBACK');
260             return false;
261         }
262
263         $avatar = $munger->getAvatar();
264         if ($avatar) {
265             try {
266                 $this->updateAvatar($avatar);
267             } catch (Exception $e) {
268                 common_log(LOG_ERR, "Exception setting OStatus avatar: " .
269                                     $e->getMessage());
270             }
271         }
272
273         return $entity;
274     }
275
276     /**
277      * Download and update given avatar image
278      * @param string $url
279      * @throws Exception in various failure cases
280      */
281     public function updateAvatar($url)
282     {
283         // @fixme this should be better encapsulated
284         // ripped from oauthstore.php (for old OMB client)
285         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
286         copy($url, $temp_filename);
287         $imagefile = new ImageFile($profile->id, $temp_filename);
288         $filename = Avatar::filename($profile->id,
289                                      image_type_to_extension($imagefile->type),
290                                      null,
291                                      common_timestamp());
292         rename($temp_filename, Avatar::path($filename));
293         if ($this->isGroup()) {
294             $group = $this->localGroup();
295             $group->setOriginal($filename);
296         } else {
297             $profile = $this->localProfile();
298             $profile->setOriginal($filename);
299         }
300     }
301
302     /**
303      * Damn dirty hack!
304      */
305     function isGroup()
306     {
307         return (strpos($this->feeduri, '/groups/') !== false);
308     }
309
310     /**
311      * Send a subscription request to the hub for this feed.
312      * The hub will later send us a confirmation POST to /main/push/callback.
313      *
314      * @return bool true on success, false on failure
315      */
316     public function subscribe($mode='subscribe')
317     {
318         if (common_config('feedsub', 'nohub')) {
319             // Fake it! We're just testing remote feeds w/o hubs.
320             return true;
321         }
322         // @fixme use the verification token
323         #$token = md5(mt_rand() . ':' . $this->feeduri);
324         #$this->verify_token = $token;
325         #$this->update(); // @fixme
326         try {
327             $callback = common_local_url('pushcallback', array('feed' => $this->id));
328             $headers = array('Content-Type: application/x-www-form-urlencoded');
329             $post = array('hub.mode' => $mode,
330                           'hub.callback' => $callback,
331                           'hub.verify' => 'async',
332                           'hub.verify_token' => $this->verify_token,
333                           'hub.secret' => $this->secret,
334                           //'hub.lease_seconds' => 0,
335                           'hub.topic' => $this->feeduri);
336             $client = new HTTPClient();
337             $response = $client->post($this->huburi, $headers, $post);
338             $status = $response->getStatus();
339             if ($status == 202) {
340                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
341                 return true;
342             } else if ($status == 204) {
343                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
344                 return true;
345             } else if ($status >= 200 && $status < 300) {
346                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
347                 return false;
348             } else {
349                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
350                 return false;
351             }
352         } catch (Exception $e) {
353             // wtf!
354             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
355             return false;
356         }
357     }
358
359     /**
360      * Save PuSH subscription confirmation.
361      * Sets approximate lease start and end times and finalizes state.
362      *
363      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
364      */
365     public function confirmSubscribe($lease_seconds=0)
366     {
367         $original = clone($this);
368
369         $this->sub_state = 'active';
370         $this->sub_start = common_sql_date(time());
371         if ($lease_seconds > 0) {
372             $this->sub_end = common_sql_date(time() + $lease_seconds);
373         } else {
374             $this->sub_end = null;
375         }
376         $this->lastupdate = common_sql_date();
377
378         return $this->update($original);
379     }
380
381     /**
382      * Save PuSH unsubscription confirmation.
383      * Wipes active PuSH sub info and resets state.
384      */
385     public function confirmUnsubscribe()
386     {
387         $original = clone($this);
388
389         $this->verify_token = null;
390         $this->secret = null;
391         $this->sub_state = null;
392         $this->sub_start = null;
393         $this->sub_end = null;
394         $this->lastupdate = common_sql_date();
395
396         return $this->update($original);
397     }
398
399     /**
400      * Send an unsubscription request to the hub for this feed.
401      * The hub will later send us a confirmation POST to /main/push/callback.
402      *
403      * @return bool true on success, false on failure
404      */
405     public function unsubscribe() {
406         return $this->subscribe('unsubscribe');
407     }
408
409     /**
410      * Read and post notices for updates from the feed.
411      * Currently assumes that all items in the feed are new,
412      * coming from a PuSH hub.
413      *
414      * @param string $xml source of Atom or RSS feed
415      * @param string $hmac X-Hub-Signature header, if present
416      */
417     public function postUpdates($xml, $hmac)
418     {
419         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml");
420
421         if ($this->secret) {
422             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
423                 $their_hmac = strtolower($matches[1]);
424                 $our_hmac = hash_hmac('sha1', $xml, $this->secret);
425                 if ($their_hmac !== $our_hmac) {
426                     common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
427                     return;
428                 }
429             } else {
430                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
431                 return;
432             }
433         } else if ($hmac) {
434             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
435             return;
436         }
437
438         require_once "XML/Feed/Parser.php";
439         $feed = new XML_Feed_Parser($xml, false, false, true);
440         $munger = new FeedMunger($feed);
441         
442         $hits = 0;
443         foreach ($feed as $index => $entry) {
444             // @fixme this might sort in wrong order if we get multiple updates
445
446             $notice = $munger->notice($index);
447
448             // Double-check for oldies
449             // @fixme this could explode horribly for multiple feeds on a blog. sigh
450             $dupe = new Notice();
451             $dupe->uri = $notice->uri;
452             if ($dupe->find(true)) {
453                 common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}");
454                 continue;
455             }
456
457             // @fixme need to ensure that groups get handled correctly
458             $saved = Notice::saveNew($notice->profile_id,
459                                      $notice->content,
460                                      'ostatus',
461                                      array('is_local' => Notice::REMOTE_OMB,
462                                            'uri' => $notice->uri,
463                                            'lat' => $notice->lat,
464                                            'lon' => $notice->lon,
465                                            'location_ns' => $notice->location_ns,
466                                            'location_id' => $notice->location_id));
467
468             /*
469             common_log(LOG_DEBUG, "going to check group delivery...");
470             if ($this->group_id) {
471                 $group = User_group::staticGet($this->group_id);
472                 if ($group) {
473                     common_log(LOG_INFO, __METHOD__ . ": saving to local shadow group $group->id $group->nickname");
474                     $groups = array($group);
475                 } else {
476                     common_log(LOG_INFO, __METHOD__ . ": lost the local shadow group?");
477                 }
478             } else {
479                 common_log(LOG_INFO, __METHOD__ . ": no local shadow groups");
480                 $groups = array();
481             }
482             common_log(LOG_DEBUG, "going to add to inboxes...");
483             $notice->addToInboxes($groups, array());
484             common_log(LOG_DEBUG, "added to inboxes.");
485             */
486
487             $hits++;
488         }
489         if ($hits == 0) {
490             common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml");
491         }
492     }
493 }