]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch 'testing' of gitorious.org:statusnet/mainline into testing
[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 OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 class Ostatus_profile extends Memcached_DataObject
26 {
27     public $__table = 'ostatus_profile';
28
29     public $uri;
30
31     public $profile_id;
32     public $group_id;
33
34     public $feeduri;
35     public $salmonuri;
36     public $avatar; // remote URL of the last avatar we saved
37
38     public $created;
39     public $modified;
40
41     public /*static*/ function staticGet($k, $v=null)
42     {
43         return parent::staticGet(__CLASS__, $k, $v);
44     }
45
46     /**
47      * return table definition for DB_DataObject
48      *
49      * DB_DataObject needs to know something about the table to manipulate
50      * instances. This method provides all the DB_DataObject needs to know.
51      *
52      * @return array array of column definitions
53      */
54
55     function table()
56     {
57         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
58                      'profile_id' => DB_DATAOBJECT_INT,
59                      'group_id' => DB_DATAOBJECT_INT,
60                      'feeduri' => DB_DATAOBJECT_STR,
61                      'salmonuri' =>  DB_DATAOBJECT_STR,
62                      'avatar' =>  DB_DATAOBJECT_STR,
63                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
64                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
65     }
66
67     static function schemaDef()
68     {
69         return array(new ColumnDef('uri', 'varchar',
70                                    255, false, 'PRI'),
71                      new ColumnDef('profile_id', 'integer',
72                                    null, true, 'UNI'),
73                      new ColumnDef('group_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('feeduri', 'varchar',
76                                    255, true, 'UNI'),
77                      new ColumnDef('salmonuri', 'text',
78                                    null, true),
79                      new ColumnDef('avatar', 'text',
80                                    null, true),
81                      new ColumnDef('created', 'datetime',
82                                    null, false),
83                      new ColumnDef('modified', 'datetime',
84                                    null, false));
85     }
86
87     /**
88      * return key definitions for DB_DataObject
89      *
90      * DB_DataObject needs to know about keys that the table has; this function
91      * defines them.
92      *
93      * @return array key definitions
94      */
95
96     function keys()
97     {
98         return array_keys($this->keyTypes());
99     }
100
101     /**
102      * return key definitions for Memcached_DataObject
103      *
104      * Our caching system uses the same key definitions, but uses a different
105      * method to get them.
106      *
107      * @return array key definitions
108      */
109
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             $object = new ActivityObject();
154             $object->type = 'http://activitystrea.ms/schema/1.0/group';
155             $object->id = $this->uri;
156             $self = $this->localGroup();
157
158             // @fixme put a standard getAvatar() interface on groups too
159             if ($self->homepage_logo) {
160                 $object->avatar = $self->homepage_logo;
161                 $map = array('png' => 'image/png',
162                              'jpg' => 'image/jpeg',
163                              'jpeg' => 'image/jpeg',
164                              'gif' => 'image/gif');
165                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
166                 if (isset($map[$extension])) {
167                     // @fixme this ain't used/saved yet
168                     $object->avatarType = $map[$extension];
169                 }
170             }
171
172             $object->link = $this->uri; // @fixme accurate?
173             return $object;
174         } else {
175             return ActivityObject::fromProfile($this->localProfile());
176         }
177     }
178
179     /**
180      * Returns an XML string fragment with profile information as an
181      * Activity Streams noun object with the given element type.
182      *
183      * Assumes that 'activity' namespace has been previously defined.
184      *
185      * @fixme replace with wrappers on asActivityObject when it's got everything.
186      *
187      * @param string $element one of 'actor', 'subject', 'object', 'target'
188      * @return string
189      */
190     function asActivityNoun($element)
191     {
192         $xs = new XMLStringer(true);
193         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
194         $avatarType = 'image/png';
195         if ($this->isGroup()) {
196             $type = 'http://activitystrea.ms/schema/1.0/group';
197             $self = $this->localGroup();
198
199             // @fixme put a standard getAvatar() interface on groups too
200             if ($self->homepage_logo) {
201                 $avatarHref = $self->homepage_logo;
202                 $map = array('png' => 'image/png',
203                              'jpg' => 'image/jpeg',
204                              'jpeg' => 'image/jpeg',
205                              'gif' => 'image/gif');
206                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
207                 if (isset($map[$extension])) {
208                     $avatarType = $map[$extension];
209                 }
210             }
211         } else {
212             $type = 'http://activitystrea.ms/schema/1.0/person';
213             $self = $this->localProfile();
214             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
215             if ($avatar) {
216                   $avatarHref = $avatar->url;
217                   $avatarType = $avatar->mediatype;
218             }
219         }
220         $xs->elementStart('activity:' . $element);
221         $xs->element(
222             'activity:object-type',
223             null,
224             $type
225         );
226         $xs->element(
227             'id',
228             null,
229             $this->uri); // ?
230         $xs->element('title', null, $self->getBestName());
231
232         $xs->element(
233             'link', array(
234                 'type' => $avatarType,
235                 'href' => $avatarHref
236             ),
237             ''
238         );
239
240         $xs->elementEnd('activity:' . $element);
241
242         return $xs->getString();
243     }
244
245     /**
246      * @return boolean true if this is a remote group
247      */
248     function isGroup()
249     {
250         if ($this->profile_id && !$this->group_id) {
251             return false;
252         } else if ($this->group_id && !$this->profile_id) {
253             return true;
254         } else if ($this->group_id && $this->profile_id) {
255             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
256         } else {
257             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
258         }
259     }
260
261     /**
262      * Subscribe a local user to this remote user.
263      * PuSH subscription will be started if necessary, and we'll
264      * send a Salmon notification to the remote server if available
265      * notifying them of the sub.
266      *
267      * @param User $user
268      * @return boolean success
269      * @throws FeedException
270      */
271     public function subscribeLocalToRemote(User $user)
272     {
273         if ($this->isGroup()) {
274             throw new ServerException("Can't subscribe to a remote group");
275         }
276
277         if ($this->subscribe()) {
278             if ($user->subscribeTo($this->localProfile())) {
279                 $this->notify($user->getProfile(), ActivityVerb::FOLLOW, $this);
280                 return true;
281             }
282         }
283         return false;
284     }
285
286     /**
287      * Mark this remote profile as subscribing to the given local user,
288      * and send appropriate notifications to the user.
289      *
290      * This will generally be in response to a subscription notification
291      * from a foreign site to our local Salmon response channel.
292      *
293      * @param User $user
294      * @return boolean success
295      */
296     public function subscribeRemoteToLocal(User $user)
297     {
298         if ($this->isGroup()) {
299             throw new ServerException("Remote groups can't subscribe to local users");
300         }
301
302         Subscription::start($this->localProfile(), $user->getProfile());
303
304         return true;
305     }
306
307     /**
308      * Send a subscription request to the hub for this feed.
309      * The hub will later send us a confirmation POST to /main/push/callback.
310      *
311      * @return bool true on success, false on failure
312      * @throws ServerException if feed state is not valid
313      */
314     public function subscribe()
315     {
316         $feedsub = FeedSub::ensureFeed($this->feeduri);
317         if ($feedsub->sub_state == 'active' || $feedsub->sub_state == 'subscribe') {
318             return true;
319         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
320             return $feedsub->subscribe();
321         } else if ('unsubscribe') {
322             throw new FeedSubException("Unsub is pending, can't subscribe...");
323         }
324     }
325
326     /**
327      * Send a PuSH unsubscription request to the hub for this feed.
328      * The hub will later send us a confirmation POST to /main/push/callback.
329      *
330      * @return bool true on success, false on failure
331      * @throws ServerException if feed state is not valid
332      */
333     public function unsubscribe() {
334         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
335         if ($feedsub->sub_state == 'active') {
336             return $feedsub->unsubscribe();
337         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive' || $feedsub->sub_state == 'unsubscribe') {
338             return true;
339         } else if ($feedsub->sub_state == 'subscribe') {
340             throw new FeedSubException("Feed is awaiting subscription, can't unsub...");
341         }
342     }
343
344     /**
345      * Check if this remote profile has any active local subscriptions, and
346      * if not drop the PuSH subscription feed.
347      *
348      * @return boolean
349      */
350     public function garbageCollect()
351     {
352         if ($this->isGroup()) {
353             $members = $this->localGroup()->getMembers(0, 1);
354             $count = $members->N;
355         } else {
356             $count = $this->localProfile()->subscriberCount();
357         }
358         if ($count == 0) {
359             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $oprofile->feeduri");
360             $this->unsubscribe();
361             return true;
362         } else {
363             return false;
364         }
365     }
366
367     /**
368      * Send an Activity Streams notification to the remote Salmon endpoint,
369      * if so configured.
370      *
371      * @param Profile $actor  Actor who did the activity
372      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
373      * @param Object  $object object of the action; must define asActivityNoun($tag)
374      */
375     public function notify($actor, $verb, $object=null)
376     {
377         if (!($actor instanceof Profile)) {
378             $type = gettype($actor);
379             if ($type == 'object') {
380                 $type = get_class($actor);
381             }
382             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
383         }
384         if ($object == null) {
385             $object = $this;
386         }
387         if ($this->salmonuri) {
388
389             $text = 'update';
390             $id = TagURI::mint('%s:%s:%s',
391                                $verb,
392                                $actor->getURI(),
393                                common_date_iso8601(time()));
394
395             // @fixme consolidate all these NS settings somewhere
396             $attributes = array('xmlns' => Activity::ATOM,
397                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
398                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
399                                 'xmlns:georss' => 'http://www.georss.org/georss',
400                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
401                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0');
402
403             $entry = new XMLStringer();
404             $entry->elementStart('entry', $attributes);
405             $entry->element('id', null, $id);
406             $entry->element('title', null, $text);
407             $entry->element('summary', null, $text);
408             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
409
410             $entry->element('activity:verb', null, $verb);
411             $entry->raw($actor->asAtomAuthor());
412             $entry->raw($actor->asActivityActor());
413             $entry->raw($object->asActivityNoun('object'));
414             $entry->elementEnd('entry');
415
416             $xml = $entry->getString();
417             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
418
419             $salmon = new Salmon(); // ?
420             return $salmon->post($this->salmonuri, $xml);
421         }
422         return false;
423     }
424
425     /**
426      * Send a Salmon notification ping immediately, and confirm that we got
427      * an acceptable response from the remote site.
428      *
429      * @param mixed $entry XML string, Notice, or Activity
430      * @return boolean success
431      */
432     public function notifyActivity($entry)
433     {
434         if ($this->salmonuri) {
435             $salmon = new Salmon();
436             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry));
437         }
438
439         return false;
440     }
441
442     /**
443      * Queue a Salmon notification for later. If queues are disabled we'll
444      * send immediately but won't get the return value.
445      *
446      * @param mixed $entry XML string, Notice, or Activity
447      * @return boolean success
448      */
449     public function notifyDeferred($entry)
450     {
451         if ($this->salmonuri) {
452             $data = array('salmonuri' => $this->salmonuri,
453                           'entry' => $this->notifyPrepXml($entry));
454
455             $qm = QueueManager::get();
456             return $qm->enqueue($data, 'salmon');
457         }
458
459         return false;
460     }
461
462     protected function notifyPrepXml($entry)
463     {
464         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
465         if (is_string($entry)) {
466             return $entry;
467         } else if ($entry instanceof Activity) {
468             return $preamble . $entry->asString(true);
469         } else if ($entry instanceof Notice) {
470             return $preamble . $entry->asAtomEntry(true, true);
471         } else {
472             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
473         }
474     }
475
476     function getBestName()
477     {
478         if ($this->isGroup()) {
479             return $this->localGroup()->getBestName();
480         } else {
481             return $this->localProfile()->getBestName();
482         }
483     }
484
485     function atomFeed($actor)
486     {
487         $feed = new Atom10Feed();
488         // @fixme should these be set up somewhere else?
489         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
490         $feed->addNamespace('thr', 'http://purl.org/syndication/thread/1.0');
491         $feed->addNamespace('georss', 'http://www.georss.org/georss');
492         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
493
494         $taguribase = common_config('integration', 'taguri');
495         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
496
497         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
498         $feed->setUpdated(time());
499         $feed->setPublished(time());
500
501         $feed->addLink(common_local_url('ApiTimelineUser',
502                                         array('id' => $actor->id,
503                                               'type' => 'atom')),
504                        array('rel' => 'self',
505                              'type' => 'application/atom+xml'));
506
507         $feed->addLink(common_local_url('userbyid',
508                                         array('id' => $actor->id)),
509                        array('rel' => 'alternate',
510                              'type' => 'text/html'));
511
512         return $feed;
513     }
514
515     /**
516      * Read and post notices for updates from the feed.
517      * Currently assumes that all items in the feed are new,
518      * coming from a PuSH hub.
519      *
520      * @param DOMDocument $feed
521      */
522     public function processFeed($feed, $source)
523     {
524         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
525         if ($entries->length == 0) {
526             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
527             return;
528         }
529
530         for ($i = 0; $i < $entries->length; $i++) {
531             $entry = $entries->item($i);
532             $this->processEntry($entry, $feed, $source);
533         }
534     }
535
536     /**
537      * Process a posted entry from this feed source.
538      *
539      * @param DOMElement $entry
540      * @param DOMElement $feed for context
541      */
542     public function processEntry($entry, $feed, $source)
543     {
544         $activity = new Activity($entry, $feed);
545
546         if ($activity->verb == ActivityVerb::POST) {
547             $this->processPost($activity, $source);
548         } else {
549             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
550         }
551     }
552
553     /**
554      * Process an incoming post activity from this remote feed.
555      * @param Activity $activity
556      * @param string $method 'push' or 'salmon'
557      * @return mixed saved Notice or false
558      * @fixme break up this function, it's getting nasty long
559      */
560     public function processPost($activity, $method)
561     {
562         if ($this->isGroup()) {
563             // A group feed will contain posts from multiple authors.
564             // @fixme validate these profiles in some way!
565             $oprofile = self::ensureActorProfile($activity);
566             if ($oprofile->isGroup()) {
567                 // Groups can't post notices in StatusNet.
568                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
569                 return false;
570             }
571         } else {
572             // Individual user feeds may contain only posts from themselves.
573             // Authorship is validated against the profile URI on upper layers,
574             // through PuSH setup or Salmon signature checks.
575             $actorUri = self::getActorProfileURI($activity);
576             if ($actorUri == $this->uri) {
577                 // Check if profile info has changed and update it
578                 $this->updateFromActivityObject($activity->actor);
579             } else {
580                 common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
581                 return false;
582             }
583             $oprofile = $this;
584         }
585
586         // The id URI will be used as a unique identifier for for the notice,
587         // protecting against duplicate saves. It isn't required to be a URL;
588         // tag: URIs for instance are found in Google Buzz feeds.
589         $sourceUri = $activity->object->id;
590         $dupe = Notice::staticGet('uri', $sourceUri);
591         if ($dupe) {
592             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
593             return false;
594         }
595
596         // We'll also want to save a web link to the original notice, if provided.
597         $sourceUrl = null;
598         if ($activity->object->link) {
599             $sourceUrl = $activity->object->link;
600         } else if ($activity->link) {
601             $sourceUrl = $activity->link;
602         } else if (preg_match('!^https?://!', $activity->object->id)) {
603             $sourceUrl = $activity->object->id;
604         }
605
606         // Get (safe!) HTML and text versions of the content
607         $rendered = $this->purify($activity->object->content);
608         $content = html_entity_decode(strip_tags($rendered));
609
610         $options = array('is_local' => Notice::REMOTE_OMB,
611                         'url' => $sourceUrl,
612                         'uri' => $sourceUri,
613                         'rendered' => $rendered,
614                         'replies' => array(),
615                         'groups' => array());
616
617         // Check for optional attributes...
618
619         if (!empty($activity->time)) {
620             $options['created'] = common_sql_date($activity->time);
621         }
622
623         if ($activity->context) {
624             // Any individual or group attn: targets?
625             $replies = $activity->context->attention;
626             $options['groups'] = $this->filterReplies($oprofile, $replies);
627             $options['replies'] = $replies;
628
629             // Maintain direct reply associations
630             // @fixme what about conversation ID?
631             if (!empty($activity->context->replyToID)) {
632                 $orig = Notice::staticGet('uri',
633                                           $activity->context->replyToID);
634                 if (!empty($orig)) {
635                     $options['reply_to'] = $orig->id;
636                 }
637             }
638
639             $location = $activity->context->location;
640             if ($location) {
641                 $options['lat'] = $location->lat;
642                 $options['lon'] = $location->lon;
643                 if ($location->location_id) {
644                     $options['location_ns'] = $location->location_ns;
645                     $options['location_id'] = $location->location_id;
646                 }
647             }
648         }
649
650         try {
651             $saved = Notice::saveNew($oprofile->profile_id,
652                                      $content,
653                                      'ostatus',
654                                      $options);
655             if ($saved) {
656                 Ostatus_source::saveNew($saved, $this, $method);
657             }
658         } catch (Exception $e) {
659             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
660             throw $e;
661         }
662         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
663         return $saved;
664     }
665
666     /**
667      * Clean up HTML
668      */
669     protected function purify($html)
670     {
671         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
672         $config = array('safe' => 1);
673         return htmLawed($html, $config);
674     }
675
676     /**
677      * Filters a list of recipient ID URIs to just those for local delivery.
678      * @param Ostatus_profile local profile of sender
679      * @param array in/out &$attention_uris set of URIs, will be pruned on output
680      * @return array of group IDs
681      */
682     protected function filterReplies($sender, &$attention_uris)
683     {
684         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
685         $groups = array();
686         $replies = array();
687         foreach ($attention_uris as $recipient) {
688             // Is the recipient a local user?
689             $user = User::staticGet('uri', $recipient);
690             if ($user) {
691                 // @fixme sender verification, spam etc?
692                 $replies[] = $recipient;
693                 continue;
694             }
695
696             // Is the recipient a remote group?
697             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
698             if ($oprofile) {
699                 if ($oprofile->isGroup()) {
700                     // Deliver to local members of this remote group.
701                     // @fixme sender verification?
702                     $groups[] = $oprofile->group_id;
703                 } else {
704                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
705                 }
706                 continue;
707             }
708
709             // Is the recipient a local group?
710             // @fixme we need a uri on user_group
711             // $group = User_group::staticGet('uri', $recipient);
712             $template = common_local_url('groupbyid', array('id' => '31337'));
713             $template = preg_quote($template, '/');
714             $template = str_replace('31337', '(\d+)', $template);
715             if (preg_match("/$template/", $recipient, $matches)) {
716                 $id = $matches[1];
717                 $group = User_group::staticGet('id', $id);
718                 if ($group) {
719                     // Deliver to all members of this local group if allowed.
720                     $profile = $sender->localProfile();
721                     if ($profile->isMember($group)) {
722                         $groups[] = $group->id;
723                     } else {
724                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
725                     }
726                     continue;
727                 } else {
728                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
729                 }
730             }
731
732             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
733
734         }
735         $attention_uris = $replies;
736         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
737         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
738         return $groups;
739     }
740
741     /**
742      * @param string $profile_url
743      * @return Ostatus_profile
744      * @throws FeedSubException
745      */
746     public static function ensureProfile($profile_uri, $hints=array())
747     {
748         // Get the canonical feed URI and check it
749         $discover = new FeedDiscovery();
750         $feeduri = $discover->discoverFromURL($profile_uri);
751
752         //$feedsub = FeedSub::ensureFeed($feeduri, $discover->feed);
753         $huburi = $discover->getAtomLink('hub');
754         $salmonuri = $discover->getAtomLink('salmon');
755
756         if (!$huburi) {
757             // We can only deal with folks with a PuSH hub
758             throw new FeedSubNoHubException();
759         }
760
761         // Try to get a profile from the feed activity:subject
762
763         $feedEl = $discover->feed->documentElement;
764
765         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
766
767         if (!empty($subject)) {
768             $subjObject = new ActivityObject($subject);
769             return self::ensureActivityObjectProfile($subjObject, $feeduri, $salmonuri, $hints);
770         }
771
772         // Otherwise, try the feed author
773
774         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
775
776         if (!empty($author)) {
777             $authorObject = new ActivityObject($author);
778             return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
779         }
780
781         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
782         // entries.
783
784         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
785
786         if (!empty($entries) && $entries->length > 0) {
787
788             $entry = $entries->item(0);
789
790             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
791
792             if (!empty($actor)) {
793                 $actorObject = new ActivityObject($actor);
794                 return self::ensureActivityObjectProfile($actorObject, $feeduri, $salmonuri, $hints);
795
796             }
797
798             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
799
800             if (!empty($author)) {
801                 $authorObject = new ActivityObject($author);
802                 return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
803             }
804         }
805
806         // XXX: make some educated guesses here
807
808         throw new FeedSubException("Can't find enough profile information to make a feed.");
809     }
810
811     /**
812      *
813      * Download and update given avatar image
814      * @param string $url
815      * @throws Exception in various failure cases
816      */
817     protected function updateAvatar($url)
818     {
819         if ($url == $this->avatar) {
820             // We've already got this one.
821             return;
822         }
823
824         if ($this->isGroup()) {
825             $self = $this->localGroup();
826         } else {
827             $self = $this->localProfile();
828         }
829         if (!$self) {
830             throw new ServerException(sprintf(
831                 _m("Tried to update avatar for unsaved remote profile %s"),
832                 $this->uri));
833         }
834
835         // @fixme this should be better encapsulated
836         // ripped from oauthstore.php (for old OMB client)
837         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
838         if (!copy($url, $temp_filename)) {
839             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
840         }
841
842         if ($this->isGroup()) {
843             $id = $this->group_id;
844         } else {
845             $id = $this->profile_id;
846         }
847         // @fixme should we be using different ids?
848         $imagefile = new ImageFile($id, $temp_filename);
849         $filename = Avatar::filename($id,
850                                      image_type_to_extension($imagefile->type),
851                                      null,
852                                      common_timestamp());
853         rename($temp_filename, Avatar::path($filename));
854         $self->setOriginal($filename);
855
856         $orig = clone($this);
857         $this->avatar = $url;
858         $this->update($orig);
859     }
860
861     /**
862      * Pull avatar URL from ActivityObject or profile hints
863      *
864      * @param ActivityObject $object
865      * @param array $hints
866      * @return mixed URL string or false
867      */
868
869     protected static function getActivityObjectAvatar($object, $hints=array())
870     {
871         if ($object->avatar) {
872             return $object->avatar;
873         } else if (array_key_exists('avatar', $hints)) {
874             return $hints['avatar'];
875         }
876         return false;
877     }
878
879     /**
880      * Get an appropriate avatar image source URL, if available.
881      *
882      * @param ActivityObject $actor
883      * @param DOMElement $feed
884      * @return string
885      */
886
887     protected static function getAvatar($actor, $feed)
888     {
889         $url = '';
890         $icon = '';
891         if ($actor->avatar) {
892             $url = trim($actor->avatar);
893         }
894         if (!$url) {
895             // Check <atom:logo> and <atom:icon> on the feed
896             $els = $feed->childNodes();
897             if ($els && $els->length) {
898                 for ($i = 0; $i < $els->length; $i++) {
899                     $el = $els->item($i);
900                     if ($el->namespaceURI == Activity::ATOM) {
901                         if (empty($url) && $el->localName == 'logo') {
902                             $url = trim($el->textContent);
903                             break;
904                         }
905                         if (empty($icon) && $el->localName == 'icon') {
906                             // Use as a fallback
907                             $icon = trim($el->textContent);
908                         }
909                     }
910                 }
911             }
912             if ($icon && !$url) {
913                 $url = $icon;
914             }
915         }
916         if ($url) {
917             $opts = array('allowed_schemes' => array('http', 'https'));
918             if (Validate::uri($url, $opts)) {
919                 return $url;
920             }
921         }
922         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
923     }
924
925     /**
926      * Fetch, or build if necessary, an Ostatus_profile for the actor
927      * in a given Activity Streams activity.
928      *
929      * @param Activity $activity
930      * @param string $feeduri if we already know the canonical feed URI!
931      * @param string $salmonuri if we already know the salmon return channel URI
932      * @return Ostatus_profile
933      */
934
935     public static function ensureActorProfile($activity, $feeduri=null, $salmonuri=null)
936     {
937         return self::ensureActivityObjectProfile($activity->actor, $feeduri, $salmonuri);
938     }
939
940     public static function ensureActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
941     {
942         $profile = self::getActivityObjectProfile($object);
943         if ($profile) {
944             $profile->updateFromActivityObject($object, $hints);
945         } else {
946             $profile = self::createActivityObjectProfile($object, $feeduri, $salmonuri, $hints);
947         }
948         return $profile;
949     }
950
951     /**
952      * @param Activity $activity
953      * @return mixed matching Ostatus_profile or false if none known
954      */
955     public static function getActorProfile($activity)
956     {
957         return self::getActivityObjectProfile($activity->actor);
958     }
959
960     protected static function getActivityObjectProfile($object)
961     {
962         $uri = self::getActivityObjectProfileURI($object);
963         return Ostatus_profile::staticGet('uri', $uri);
964     }
965
966     protected static function getActorProfileURI($activity)
967     {
968         return self::getActivityObjectProfileURI($activity->actor);
969     }
970
971     /**
972      * @param Activity $activity
973      * @return string
974      * @throws ServerException
975      */
976     protected static function getActivityObjectProfileURI($object)
977     {
978         $opts = array('allowed_schemes' => array('http', 'https'));
979         if ($object->id && Validate::uri($object->id, $opts)) {
980             return $object->id;
981         }
982         if ($object->link && Validate::uri($object->link, $opts)) {
983             return $object->link;
984         }
985         throw new ServerException("No author ID URI found");
986     }
987
988     /**
989      * @fixme validate stuff somewhere
990      */
991
992     protected static function createActorProfile($activity, $feeduri=null, $salmonuri=null)
993     {
994         $actor = $activity->actor;
995
996         self::createActivityObjectProfile($actor, $feeduri, $salmonuri);
997     }
998
999     /**
1000      * Create local ostatus_profile and profile/user_group entries for
1001      * the provided remote user or group.
1002      *
1003      * @param ActivityObject $object
1004      * @param string $feeduri
1005      * @param string $salmonuri
1006      * @param array $hints
1007      *
1008      * @fixme fold $feeduri/$salmonuri into $hints
1009      * @return Ostatus_profile
1010      */
1011     protected static function createActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
1012     {
1013         $homeuri  = $object->id;
1014
1015         if (!$homeuri) {
1016             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1017             throw new ServerException("No profile URI");
1018         }
1019
1020         if (empty($feeduri)) {
1021             if (array_key_exists('feedurl', $hints)) {
1022                 $feeduri = $hints['feedurl'];
1023             }
1024         }
1025
1026         if (empty($salmonuri)) {
1027             if (array_key_exists('salmon', $hints)) {
1028                 $salmonuri = $hints['salmon'];
1029             }
1030         }
1031
1032         if (!$feeduri || !$salmonuri) {
1033             // Get the canonical feed URI and check it
1034             $discover = new FeedDiscovery();
1035             $feeduri = $discover->discoverFromURL($homeuri);
1036
1037             $huburi = $discover->getAtomLink('hub');
1038             $salmonuri = $discover->getAtomLink('salmon');
1039
1040             if (!$huburi) {
1041                 // We can only deal with folks with a PuSH hub
1042                 throw new FeedSubNoHubException();
1043             }
1044         }
1045
1046         $oprofile = new Ostatus_profile();
1047
1048         $oprofile->uri        = $homeuri;
1049         $oprofile->feeduri    = $feeduri;
1050         $oprofile->salmonuri  = $salmonuri;
1051
1052         $oprofile->created    = common_sql_now();
1053         $oprofile->modified   = common_sql_now();
1054
1055         if ($object->type == ActivityObject::PERSON) {
1056             $profile = new Profile();
1057             self::updateProfile($profile, $object, $hints);
1058             $profile->created  = common_sql_now();
1059
1060             $oprofile->profile_id = $profile->insert();
1061             if (!$oprofile->profile_id) {
1062                 throw new ServerException("Can't save local profile");
1063             }
1064         } else {
1065             $group = new User_group();
1066             $group->created = common_sql_now();
1067             self::updateGroup($group, $object, $hints);
1068
1069             $oprofile->group_id = $group->insert();
1070             if (!$oprofile->group_id) {
1071                 throw new ServerException("Can't save local profile");
1072             }
1073         }
1074
1075         $ok = $oprofile->insert();
1076
1077         if ($ok) {
1078             $avatar = self::getActivityObjectAvatar($object, $hints);
1079             if ($avatar) {
1080                 $oprofile->updateAvatar($avatar);
1081             }
1082             return $oprofile;
1083         } else {
1084             throw new ServerException("Can't save OStatus profile");
1085         }
1086     }
1087
1088     /**
1089      * Save any updated profile information to our local copy.
1090      * @param ActivityObject $object
1091      * @param array $hints
1092      */
1093     public function updateFromActivityObject($object, $hints=array())
1094     {
1095         if ($this->isGroup()) {
1096             $group = $this->localGroup();
1097             self::updateGroup($group, $object, $hints);
1098         } else {
1099             $profile = $this->localProfile();
1100             self::updateProfile($profile, $object, $hints);
1101         }
1102         $avatar = self::getActivityObjectAvatar($object, $hints);
1103         if ($avatar) {
1104             $this->updateAvatar($avatar);
1105         }
1106     }
1107
1108     protected static function updateProfile($profile, $object, $hints=array())
1109     {
1110         $orig = clone($profile);
1111
1112         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1113         $profile->fullname = $object->title;
1114         if (!empty($object->link)) {
1115             $profile->profileurl = $object->link;
1116         } else if (array_key_exists('profileurl', $hints)) {
1117             $profile->profileurl = $hints['profileurl'];
1118         }
1119
1120         $profile->bio      = self::getActivityObjectBio($object, $hints);
1121         $profile->location = self::getActivityObjectLocation($object, $hints);
1122         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1123
1124         if (!empty($object->geopoint)) {
1125             $location = ActivityContext::locationFromPoint($object->geopoint);
1126             if (!empty($location)) {
1127                 $profile->lat = $location->lat;
1128                 $profile->lon = $location->lon;
1129             }
1130         }
1131
1132         // @fixme tags/categories
1133         // @todo tags from categories
1134
1135         if ($profile->id) {
1136             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1137             $profile->update($orig);
1138         }
1139     }
1140
1141     protected static function updateGroup($group, $object, $hints=array())
1142     {
1143         $orig = clone($group);
1144
1145         // @fixme need to make nick unique etc *hack hack*
1146         $group->nickname = self::getActivityObjectNickname($object, $hints);
1147         $group->fullname = $object->title;
1148
1149         // @fixme no canonical profileurl; using homepage instead for now
1150         $group->homepage = $object->id;
1151
1152         // @fixme homepage
1153         // @fixme bio
1154         // @fixme tags/categories
1155         // @fixme location?
1156         // @todo tags from categories
1157         // @todo lat/lon/location?
1158
1159         if ($group->id) {
1160             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1161             $group->update($orig);
1162         }
1163     }
1164
1165     protected static function getActivityObjectHomepage($object, $hints=array())
1166     {
1167         $homepage = null;
1168         $poco     = $object->poco;
1169
1170         if (!empty($poco)) {
1171             $url = $poco->getPrimaryURL();
1172             if ($url->type == 'homepage') {
1173                 $homepage = $url->value;
1174             }
1175         }
1176
1177         // @todo Try for a another PoCo URL?
1178
1179         return $homepage;
1180     }
1181
1182     protected static function getActivityObjectLocation($object, $hints=array())
1183     {
1184         $location = null;
1185
1186         if (!empty($object->poco)) {
1187             if (isset($object->poco->address->formatted)) {
1188                 $location = $object->poco->address->formatted;
1189                 if (mb_strlen($location) > 255) {
1190                     $location = mb_substr($note, 0, 255 - 3) . ' … ';
1191                 }
1192             }
1193         }
1194
1195         // @todo Try to find location some othe way? Via goerss point?
1196
1197         return $location;
1198     }
1199
1200     protected static function getActivityObjectBio($object, $hints=array())
1201     {
1202         $bio  = null;
1203
1204         if (!empty($object->poco)) {
1205             $note = $object->poco->note;
1206             if (!empty($note)) {
1207                 if (mb_strlen($note) > Profile::maxBio()) {
1208                     // XXX: truncate ok?
1209                     $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' … ';
1210                 } else {
1211                     $bio = $note;
1212                 }
1213             }
1214         }
1215
1216         // @todo Try to get bio info some other way?
1217
1218         return $bio;
1219     }
1220
1221     protected static function getActivityObjectNickname($object, $hints=array())
1222     {
1223         if ($object->poco) {
1224             if (!empty($object->poco->preferredUsername)) {
1225                 return common_nicknamize($object->poco->preferredUsername);
1226             }
1227         }
1228         if (!empty($object->nickname)) {
1229             return common_nicknamize($object->nickname);
1230         }
1231
1232         // Try the definitive ID
1233
1234         $nickname = self::nicknameFromURI($object->id);
1235
1236         // Try a Webfinger if one was passed (way) down
1237
1238         if (empty($nickname)) {
1239             if (array_key_exists('webfinger', $hints)) {
1240                 $nickname = self::nicknameFromURI($hints['webfinger']);
1241             }
1242         }
1243
1244         // Try the name
1245
1246         if (empty($nickname)) {
1247             $nickname = common_nicknamize($object->title);
1248         }
1249
1250         return $nickname;
1251     }
1252
1253     protected static function nicknameFromURI($uri)
1254     {
1255         preg_match('/(\w+):/', $uri, $matches);
1256
1257         $protocol = $matches[1];
1258
1259         switch ($protocol) {
1260         case 'acct':
1261         case 'mailto':
1262             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1263                 return common_canonical_nickname($matches[1]);
1264             }
1265             return null;
1266         case 'http':
1267             return common_url_to_nickname($uri);
1268             break;
1269         default:
1270             return null;
1271         }
1272     }
1273
1274     public static function ensureWebfinger($addr)
1275     {
1276         // First, look it up
1277
1278         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1279
1280         if (!empty($oprofile)) {
1281             return $oprofile;
1282         }
1283
1284         // Now, try some discovery
1285
1286         $wf = new Webfinger();
1287
1288         $result = $wf->lookup($addr);
1289
1290         if (!$result) {
1291             return null;
1292         }
1293
1294         foreach ($result->links as $link) {
1295             switch ($link['rel']) {
1296             case Webfinger::PROFILEPAGE:
1297                 $profileUrl = $link['href'];
1298                 break;
1299             case 'salmon':
1300                 $salmonEndpoint = $link['href'];
1301                 break;
1302             case Webfinger::UPDATESFROM:
1303                 $feedUrl = $link['href'];
1304                 break;
1305             default:
1306                 common_log(LOG_NOTICE, "Don't know what to do with rel = '{$link['rel']}'");
1307                 break;
1308             }
1309         }
1310
1311         $hints = array('webfinger' => $addr,
1312                        'profileurl' => $profileUrl,
1313                        'feedurl' => $feedUrl,
1314                        'salmon' => $salmonEndpoint);
1315
1316         // If we got a feed URL, try that
1317
1318         if (isset($feedUrl)) {
1319             try {
1320                 $oprofile = self::ensureProfile($feedUrl, $hints);
1321                 return $oprofile;
1322             } catch (Exception $e) {
1323                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1324                 // keep looking
1325             }
1326         }
1327
1328         // If we got a profile page, try that!
1329
1330         if (isset($profileUrl)) {
1331             try {
1332                 $oprofile = self::ensureProfile($profileUrl, $hints);
1333                 return $oprofile;
1334             } catch (Exception $e) {
1335                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1336                 // keep looking
1337             }
1338         }
1339
1340         // XXX: try hcard
1341         // XXX: try FOAF
1342
1343         if (isset($salmonEndpoint)) {
1344
1345             // An account URL, a salmon endpoint, and a dream? Not much to go
1346             // on, but let's give it a try
1347
1348             $uri = 'acct:'.$addr;
1349
1350             $profile = new Profile();
1351
1352             $profile->nickname = self::nicknameFromUri($uri);
1353             $profile->created  = common_sql_now();
1354
1355             if (isset($profileUrl)) {
1356                 $profile->profileurl = $profileUrl;
1357             }
1358
1359             $profile_id = $profile->insert();
1360
1361             if (!$profile_id) {
1362                 common_log_db_error($profile, 'INSERT', __FILE__);
1363                 throw new Exception("Couldn't save profile for '$addr'");
1364             }
1365
1366             $oprofile = new Ostatus_profile();
1367
1368             $oprofile->uri        = $uri;
1369             $oprofile->salmonuri  = $salmonEndpoint;
1370             $oprofile->profile_id = $profile_id;
1371             $oprofile->created    = common_sql_now();
1372
1373             if (isset($feedUrl)) {
1374                 $profile->feeduri = $feedUrl;
1375             }
1376
1377             $result = $oprofile->insert();
1378
1379             if (!$result) {
1380                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1381                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1382             }
1383
1384             return $oprofile;
1385         }
1386
1387         return null;
1388     }
1389 }