]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch 'testing' of gitorious.org:statusnet/mainline into 0.9.x
[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      * Returns an XML string fragment with profile information as an
304      * Activity Streams noun object with the given element type.
305      *
306      * Assumes that 'activity' namespace has been previously defined.
307      *
308      * @param string $element one of 'actor', 'subject', 'object', 'target'
309      * @return string
310      */
311     function asActivityNoun($element)
312     {
313         $xs = new XMLStringer(true);
314
315         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
316         $avatarType = 'image/png';
317         if ($this->isGroup()) {
318             $type = 'http://activitystrea.ms/schema/1.0/group';
319             $self = $this->localGroup();
320
321             // @fixme put a standard getAvatar() interface on groups too
322             if ($self->homepage_logo) {
323                 $avatarHref = $self->homepage_logo;
324                 $map = array('png' => 'image/png',
325                              'jpg' => 'image/jpeg',
326                              'jpeg' => 'image/jpeg',
327                              'gif' => 'image/gif');
328                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
329                 if (isset($map[$extension])) {
330                     $avatarType = $map[$extension];
331                 }
332             }
333         } else {
334             $type = 'http://activitystrea.ms/schema/1.0/person';
335             $self = $this->localProfile();
336             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
337             if ($avatar) {
338                 $avatarHref = $avatar->
339                 $avatarType = $avatar->mediatype;
340             }
341         }
342         $xs->elementStart('activity:' . $element);
343         $xs->element(
344             'activity:object-type',
345             null,
346             $type
347         );
348         $xs->element(
349             'id',
350             null,
351             $this->homeuri); // ?
352         $xs->element('title', null, $self->getBestName());
353
354         $xs->element(
355             'link', array(
356                 'type' => $avatarType,
357                 'href' => $avatarHref
358             ),
359             ''
360         );
361
362         $xs->elementEnd('activity:' . $element);
363
364         return $xs->getString();
365     }
366
367     /**
368      * Damn dirty hack!
369      */
370     function isGroup()
371     {
372         return (strpos($this->feeduri, '/groups/') !== false);
373     }
374
375     /**
376      * Send a subscription request to the hub for this feed.
377      * The hub will later send us a confirmation POST to /main/push/callback.
378      *
379      * @return bool true on success, false on failure
380      */
381     public function subscribe($mode='subscribe')
382     {
383         if (common_config('feedsub', 'nohub')) {
384             // Fake it! We're just testing remote feeds w/o hubs.
385             return true;
386         }
387         // @fixme use the verification token
388         #$token = md5(mt_rand() . ':' . $this->feeduri);
389         #$this->verify_token = $token;
390         #$this->update(); // @fixme
391         try {
392             $callback = common_local_url('pushcallback', array('feed' => $this->id));
393             $headers = array('Content-Type: application/x-www-form-urlencoded');
394             $post = array('hub.mode' => $mode,
395                           'hub.callback' => $callback,
396                           'hub.verify' => 'async',
397                           'hub.verify_token' => $this->verify_token,
398                           'hub.secret' => $this->secret,
399                           //'hub.lease_seconds' => 0,
400                           'hub.topic' => $this->feeduri);
401             $client = new HTTPClient();
402             $response = $client->post($this->huburi, $headers, $post);
403             $status = $response->getStatus();
404             if ($status == 202) {
405                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
406                 return true;
407             } else if ($status == 204) {
408                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
409                 return true;
410             } else if ($status >= 200 && $status < 300) {
411                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
412                 return false;
413             } else {
414                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
415                 return false;
416             }
417         } catch (Exception $e) {
418             // wtf!
419             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
420             return false;
421         }
422     }
423
424     /**
425      * Save PuSH subscription confirmation.
426      * Sets approximate lease start and end times and finalizes state.
427      *
428      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
429      */
430     public function confirmSubscribe($lease_seconds=0)
431     {
432         $original = clone($this);
433
434         $this->sub_state = 'active';
435         $this->sub_start = common_sql_date(time());
436         if ($lease_seconds > 0) {
437             $this->sub_end = common_sql_date(time() + $lease_seconds);
438         } else {
439             $this->sub_end = null;
440         }
441         $this->lastupdate = common_sql_date();
442
443         return $this->update($original);
444     }
445
446     /**
447      * Save PuSH unsubscription confirmation.
448      * Wipes active PuSH sub info and resets state.
449      */
450     public function confirmUnsubscribe()
451     {
452         $original = clone($this);
453
454         $this->verify_token = null;
455         $this->secret = null;
456         $this->sub_state = null;
457         $this->sub_start = null;
458         $this->sub_end = null;
459         $this->lastupdate = common_sql_date();
460
461         return $this->update($original);
462     }
463
464     /**
465      * Send a PuSH unsubscription request to the hub for this feed.
466      * The hub will later send us a confirmation POST to /main/push/callback.
467      *
468      * @return bool true on success, false on failure
469      */
470     public function unsubscribe() {
471         return $this->subscribe('unsubscribe');
472     }
473
474     /**
475      * Send an Activity Streams notification to the remote Salmon endpoint,
476      * if so configured.
477      *
478      * @param Profile $actor
479      * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN
480      * @param $object object of the action; if null, the remote entity itself is assumed
481      */
482     public function notify(Profile $actor, $verb, $object=null)
483     {
484         if ($object == null) {
485             $object = $this;
486         }
487         if ($this->salmonuri) {
488             $text = 'update'; // @fixme
489             $id = 'tag:' . common_config('site', 'server') . 
490                 ':' . $verb .
491                 ':' . $actor->id .
492                 ':' . time(); // @fixme
493
494             $entry = new Atom10Entry();
495             $entry->elementStart('entry');
496             $entry->element('id', null, $id);
497             $entry->element('title', null, $text);
498             $entry->element('summary', null, $text);
499             $entry->element('published', null, common_date_w3dtf());
500
501             $entry->element('activity:verb', null, $verb);
502             $entry->raw($profile->asAtomAuthor());
503             $entry->raw($profile->asActivityActor());
504             $entry->raw($object->asActivityNoun('object'));
505             $entry->elmentEnd('entry');
506
507             $feed = $this->atomFeed($actor);
508             $feed->initFeed();
509             $feed->addEntry($entry);
510             $feed->renderEntries();
511             $feed->endFeed();
512
513             $xml = $feed->getString();
514             common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml");
515
516             $salmon = new Salmon(); // ?
517             $salmon->post($this->salmonuri, $xml);
518         }
519     }
520
521     function getBestName()
522     {
523         if ($this->isGroup()) {
524             return $this->localGroup()->getBestName();
525         } else {
526             return $this->localProfile()->getBestName();
527         }
528     }
529
530     function atomFeed($actor)
531     {
532         $feed = new Atom10Feed();
533         // @fixme should these be set up somewhere else?
534         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
535         $feed->addNamesapce('thr', 'http://purl.org/syndication/thread/1.0');
536         $feed->addNamespace('georss', 'http://www.georss.org/georss');
537         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
538
539         $taguribase = common_config('integration', 'taguri');
540         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
541
542         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
543         $feed->setUpdated(time());
544         $feed->setPublished(time());
545
546         $feed->addLink(common_url('ApiTimelineUser',
547                                   array('id' => $actor->id,
548                                         'type' => 'atom')),
549                        array('rel' => 'self',
550                              'type' => 'application/atom+xml'));
551
552         $feed->addLink(common_url('userbyid',
553                                   array('id' => $actor->id)),
554                        array('rel' => 'alternate',
555                              'type' => 'text/html'));
556
557         return $feed;
558     }
559
560     /**
561      * Read and post notices for updates from the feed.
562      * Currently assumes that all items in the feed are new,
563      * coming from a PuSH hub.
564      *
565      * @param string $xml source of Atom or RSS feed
566      * @param string $hmac X-Hub-Signature header, if present
567      */
568     public function postUpdates($xml, $hmac)
569     {
570         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml");
571
572         if ($this->secret) {
573             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
574                 $their_hmac = strtolower($matches[1]);
575                 $our_hmac = hash_hmac('sha1', $xml, $this->secret);
576                 if ($their_hmac !== $our_hmac) {
577                     common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
578                     return;
579                 }
580             } else {
581                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
582                 return;
583             }
584         } else if ($hmac) {
585             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
586             return;
587         }
588
589         require_once "XML/Feed/Parser.php";
590         $feed = new XML_Feed_Parser($xml, false, false, true);
591         $munger = new FeedMunger($feed);
592         
593         $hits = 0;
594         foreach ($feed as $index => $entry) {
595             // @fixme this might sort in wrong order if we get multiple updates
596
597             $notice = $munger->notice($index);
598
599             // Double-check for oldies
600             // @fixme this could explode horribly for multiple feeds on a blog. sigh
601             $dupe = new Notice();
602             $dupe->uri = $notice->uri;
603             if ($dupe->find(true)) {
604                 common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}");
605                 continue;
606             }
607
608             // @fixme need to ensure that groups get handled correctly
609             $saved = Notice::saveNew($notice->profile_id,
610                                      $notice->content,
611                                      'ostatus',
612                                      array('is_local' => Notice::REMOTE_OMB,
613                                            'uri' => $notice->uri,
614                                            'lat' => $notice->lat,
615                                            'lon' => $notice->lon,
616                                            'location_ns' => $notice->location_ns,
617                                            'location_id' => $notice->location_id));
618
619             /*
620             common_log(LOG_DEBUG, "going to check group delivery...");
621             if ($this->group_id) {
622                 $group = User_group::staticGet($this->group_id);
623                 if ($group) {
624                     common_log(LOG_INFO, __METHOD__ . ": saving to local shadow group $group->id $group->nickname");
625                     $groups = array($group);
626                 } else {
627                     common_log(LOG_INFO, __METHOD__ . ": lost the local shadow group?");
628                 }
629             } else {
630                 common_log(LOG_INFO, __METHOD__ . ": no local shadow groups");
631                 $groups = array();
632             }
633             common_log(LOG_DEBUG, "going to add to inboxes...");
634             $notice->addToInboxes($groups, array());
635             common_log(LOG_DEBUG, "added to inboxes.");
636             */
637
638             $hits++;
639         }
640         if ($hits == 0) {
641             common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml");
642         }
643     }
644 }